From 8d3a35fbf80a55157c4e44a254e099d19c15cee1 Mon Sep 17 00:00:00 2001 From: Maksim Davydov Date: Thu, 17 Sep 2026 14:12:17 +0300 Subject: [PATCH 1/2] IGNITE-29049 [ducktests] Restart-safe JMX client and exact metric registry lookup Metric registry MBeans were looked up with ad-hoc patterns at every call site, and the node's JMX client was memoized for the whole life of the node object. * metric_registry_pattern(registry) builds the exact MBean name pattern of a registry: split at the first dot like JmxMetricExporterSpi, values spelled like IgniteUtils.escapeObjectNameValue(), escaped for grep -E, anchored at the end of the line ('name' sorts last). A registry without a group is filtered from the system view of the same name. The old 'group=cacheGroups.*name="%s"' pattern could not find an alphanumeric (unquoted) name at all. Names with a quote, a backslash or '?' are rejected, as the jmxterm command quoting cannot carry them. * The node's JmxClient is rebuilt on every start_node(). Before, a restarted node kept the pid and the clsLdr-bearing MBean names of its previous JVM, so callers had to build a client of their own. JmxClient takes an explicit pid for the CDC JVM. * node.metric_registry_mbean() and JmxMBean.value()/bool_value() replace the hand-built patterns and next(mbean.X) reads in rebalance, dump, snapshot and CDC. check_jmx_utils.py runs every pattern case through Python re and grep -E. --- .../tests/checks/utils/check_jmx_utils.py | 124 +++++++++++++ .../services/utils/cdc/cdc_helper.py | 8 +- .../services/utils/control_utility.py | 13 +- .../ignitetest/services/utils/ignite_aware.py | 6 +- .../ignitetest/services/utils/jmx_utils.py | 166 +++++++++++++++--- .../tests/ignitetest/tests/dump_test.py | 4 +- .../tests/ignitetest/tests/rebalance/util.py | 8 +- 7 files changed, 282 insertions(+), 47 deletions(-) create mode 100644 modules/ducktests/tests/checks/utils/check_jmx_utils.py diff --git a/modules/ducktests/tests/checks/utils/check_jmx_utils.py b/modules/ducktests/tests/checks/utils/check_jmx_utils.py new file mode 100644 index 0000000000000..42c24ee3798e1 --- /dev/null +++ b/modules/ducktests/tests/checks/utils/check_jmx_utils.py @@ -0,0 +1,124 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Checks the MBean name pattern of a metric registry. + +The pattern is grepped against the MBean list of a node, so it is checked here the same way: +the match has to cover the WHOLE object name, since that name is what the following +'get -b ' is issued against. Every case runs through Python's re and, where a grep is +installed, through 'grep -E' itself - the pattern is an ERE, not a Python regular expression. +""" +import re +import shutil +import subprocess + +import pytest + +from ignitetest.services.utils.jmx_utils import metric_registry_pattern + +# MBean names as a node prints them: the properties are ordered alphabetically, which puts +# the instance name between the group and the name. +PREFIX = "org.apache:clsLdr=3e2a56ab,group=%s,igniteInstanceName=ducker01,name=%s" +NO_GROUP_PREFIX = "org.apache:clsLdr=3e2a56ab,igniteInstanceName=ducker01,name=%s" + +GREP = shutil.which("grep") + + +def python_grep(pattern, negative_pattern, mbean_name): + """ + :return: The part of the name the patterns leave, as 'grep -E -o | grep -E -v' would return it. + """ + match = re.search(pattern, mbean_name) + + if not match or (negative_pattern and re.search(negative_pattern, match.group())): + return None + + return match.group() + + +def real_grep(tmp_path, pattern, negative_pattern, mbean_name): + """ + :return: The same as :func:`python_grep`, but produced by 'grep -E' itself. The patterns go + through files, since a quote in a command line argument does not survive Windows. + """ + def grep(flag, grep_pattern, text): + pattern_file = tmp_path / f"pattern{flag}" + pattern_file.write_bytes((grep_pattern + "\n").encode()) + + return subprocess.run([GREP, "-E", flag, "-f", str(pattern_file)], input=text.encode(), + capture_output=True).stdout.decode() + + out = grep("-o", pattern, mbean_name + "\n") + + if out and negative_pattern: + out = grep("-v", negative_pattern, out) + + return out.strip() or None + + +# (registry, MBean name, whether the registry's pattern must match that name) +CASES = [ + # A purely alphanumeric name is registered without quotes. + ("cache.myCache", PREFIX % ("cache", "myCache"), True), + ("cache.cache_1", PREFIX % ("cache", "cache_1"), True), + # Anything else - a dash is enough - is registered quoted, closing quote included. + ("cache.my-cache", PREFIX % ("cache", '"my-cache"'), True), + ("cache.mdc-demo-backup-filter", PREFIX % ("cache", '"mdc-demo-backup-filter"'), True), + # Only the FIRST dot splits the registry, so the rest of it is the MBean name. + ("io.dataregion.default", PREFIX % ("io", '"dataregion.default"'), True), + # 'cache' must not pick up 'cacheGroups': the two hold different metrics of the same cache. + ("cache.my-cache", PREFIX % ("cacheGroups", '"my-cache"'), False), + ("cacheGroups.my-cache", PREFIX % ("cacheGroups", '"my-cache"'), True), + # 'name' sorts last, so the pattern ends at the end of the line and a longer name does not answer. + ("cache.myCache", PREFIX % ("cache", "myCacheV2"), False), + ("cache.my-cache", PREFIX % ("cache", '"my-cache-2"'), False), + # The name is matched literally, ERE metacharacters included. + ("cache.my.cache", PREFIX % ("cache", '"myXcache"'), False), + ("cache.my.cache", PREFIX % ("cache", '"my.cache"'), True), + ("cache.a(b)+c", PREFIX % ("cache", '"a(b)+c"'), True), + ("cache.a(b)+c", PREFIX % ("cache", '"abbc"'), False), + # Characters that are special only right after an opening '[' or '{' stay unescaped. + ("cache.a]b}c", PREFIX % ("cache", '"a]b}c"'), True), + ("cache.a[b]{2}", PREFIX % ("cache", '"a[b]{2}"'), True), + ("cache.a[b]{2}", PREFIX % ("cache", '"abb"'), False), + # The exporter escapes '*' inside the quotes. + ("cache.a*b", PREFIX % ("cache", r'"a\*b"'), True), + # A registry without a dot has no group, and must not pick up the system view of the same name. + ("snapshot", NO_GROUP_PREFIX % "snapshot", True), + ("snapshot", PREFIX % ("views", "snapshot"), False), + ("cdc", PREFIX % ("cdc", "consumer"), False), +] + + +class CheckMetricRegistryPattern: + """ + Checks that the pattern covers how the JMX exporter really names a registry. + """ + @pytest.mark.parametrize(["registry", "mbean_name", "expected"], CASES) + def check_the_pattern_matches_exactly_the_registry(self, registry, mbean_name, expected): + assert python_grep(*metric_registry_pattern(registry), mbean_name) == (mbean_name if expected else None) + + @pytest.mark.skipif(GREP is None, reason="grep is not installed") + @pytest.mark.parametrize(["registry", "mbean_name", "expected"], CASES) + def check_grep_agrees(self, tmp_path, registry, mbean_name, expected): + assert real_grep(tmp_path, *metric_registry_pattern(registry), mbean_name) == \ + (mbean_name if expected else None) + + @pytest.mark.parametrize("registry", ["cache.it's", 'cache.a"b', "cache.a\\b", "cache.a?b"]) + def check_a_name_the_jmxterm_commands_cannot_carry_is_rejected(self, registry): + """The MBean name goes through the shell quoting of the grep and jmxterm commands.""" + with pytest.raises(ValueError): + metric_registry_pattern(registry) diff --git a/modules/ducktests/tests/ignitetest/services/utils/cdc/cdc_helper.py b/modules/ducktests/tests/ignitetest/services/utils/cdc/cdc_helper.py index 9965dbde21f30..0b8f83fb93a08 100644 --- a/modules/ducktests/tests/ignitetest/services/utils/cdc/cdc_helper.py +++ b/modules/ducktests/tests/ignitetest/services/utils/cdc/cdc_helper.py @@ -207,8 +207,6 @@ def last_ignite_cdc_event_time(ignite_cdc): :return: Timestamp of the last CDC event (unix time in seconds). """ def last_event_time_on(node): - jmx_client = JmxClient(node) - if isinstance(ignite_cdc, IgniteCdcUtility): main_java_class = ignite_cdc.APP_SERVICE_CLASS else: @@ -219,12 +217,12 @@ def last_event_time_on(node): if len(pids) == 0: raise AssertionError("ignite_cdc java process is not found on node: " + node.account.hostname) - jmx_client.pid = pids[0] + jmx_client = JmxClient(node, pids[0]) try: - mbean = jmx_client.find_mbean('.*name=cdc.*') + mbean = jmx_client.find_metric_registry('cdc') - return int(next(mbean.LastEventTime).strip()) + return int(mbean.value("LastEventTime")) except (StopIteration, RemoteCommandError): ignite_cdc.logger.warn("Filed to read LastEventTime metric from ignite_cdc, node: " + node.account.hostname) diff --git a/modules/ducktests/tests/ignitetest/services/utils/control_utility.py b/modules/ducktests/tests/ignitetest/services/utils/control_utility.py index 8ace03db5ae8d..329e5321974e5 100644 --- a/modules/ducktests/tests/ignitetest/services/utils/control_utility.py +++ b/modules/ducktests/tests/ignitetest/services/utils/control_utility.py @@ -28,7 +28,6 @@ from ignitetest.services.utils.auth import get_credentials, is_auth_enabled from ignitetest.services.utils.ignite_spec import envs_to_exports from ignitetest.services.utils.ssl.ssl_params import get_ssl_params, is_ssl_enabled, IGNITE_ADMIN_ALIAS -from ignitetest.services.utils.jmx_utils import JmxClient from ignitetest.utils.version import V_2_11_0 @@ -303,17 +302,17 @@ def snapshot_create(self, snapshot_name: str, timeout_sec: int = 60): while datetime.now() < delta_time: for node in self._cluster.nodes: - mbean = JmxClient(node).find_mbean('.*name=snapshot.*', negative_pattern='group=views') + mbean = node.metric_registry_mbean('snapshot') - if snapshot_name != next(mbean.LastSnapshotName, ""): + if snapshot_name != mbean.value("LastSnapshotName", ""): continue - start_time = int(next(mbean.LastSnapshotStartTime)) - end_time = int(next(mbean.LastSnapshotEndTime)) - err_msg = next(mbean.LastSnapshotErrorMessage) + start_time = int(mbean.value("LastSnapshotStartTime")) + end_time = int(mbean.value("LastSnapshotEndTime")) + err_msg = mbean.value("LastSnapshotErrorMessage") if (start_time < end_time) and (err_msg == ''): - assert snapshot_name == next(mbean.LastSnapshotName) + assert snapshot_name == mbean.value("LastSnapshotName") return raise TimeoutError(f'Failed to wait for the snapshot operation to complete: ' diff --git a/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py b/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py index 8bd758f339d04..0f7079d75b157 100644 --- a/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py +++ b/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py @@ -37,7 +37,7 @@ from ignitetest.services.utils.background_thread import BackgroundThreadService from ignitetest.services.utils.concurrent import CountDownLatch, AtomicValue from ignitetest.services.utils.ignite_spec import resolve_spec, SHARED_PREPARED_FILE -from ignitetest.services.utils.jmx_utils import ignite_jmx_mixin, JmxClient +from ignitetest.services.utils.jmx_utils import ignite_jmx_mixin from ignitetest.services.utils.jvm_utils import JvmProcessMixin, JvmVersionMixin from ignitetest.services.utils.log_utils import monitor_log from ignitetest.services.utils.path import IgnitePathAware @@ -600,10 +600,10 @@ def await_rebalance(self, timeout_sec=600): node = random.choice(self.alive_nodes) rebalanced = False - mbean = JmxClient(node).find_mbean('.*name=cluster') + mbean = node.metric_registry_mbean('cluster') while datetime.now() < delta_time and not rebalanced: - rebalanced = next(mbean.Rebalanced) == 'true' + rebalanced = mbean.bool_value("Rebalanced") if rebalanced: return diff --git a/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py b/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py index 48cfe00003f53..60bfb14a2e443 100644 --- a/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py +++ b/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py @@ -20,21 +20,87 @@ import os import re -from ignitetest.services.utils.decorators import memoize from ignitetest.services.utils.jvm_utils import java_version, java_major_version +_NO_DEFAULT = object() + +# Not allowed in a metric registry name: the single quote would break the quoted grep argument, and the +# $'...' quoting of the jmxterm commands turns the exporter's \" and \? escapes, and a lone backslash, into +# something else. +_UNSUPPORTED_REGISTRY_CHARS = frozenset("'\"\\?") + + +def _object_name_value(value): + """ + Spells a property value the way IgniteUtils.escapeObjectNameValue() does: as is when it is + purely alphanumeric or underscore, otherwise quoted, with backslash, quote, '?' and '*' escaped. + """ + if re.fullmatch(r'[A-Za-z0-9_]+', value): + return value + + return '"' + re.sub(r'([\\"?*])', r'\\\1', value) + '"' + + +def _ere_escape(value): + """ + Escapes a literal for a POSIX extended regular expression, which is what 'grep -E' reads - + re.escape() escapes for the Python dialect instead. + """ + return re.sub(r'([[\\.^$*+?(){|])', r'\\\1', value) + + +def metric_registry_pattern(registry): + """ + Builds the MBean name pattern of a single metric registry. + + The JMX metric exporter splits a registry name at its FIRST dot and makes the head the + MBean group and the tail its name: ``cache.myCache`` becomes ``group=cache`` plus + ``name=myCache``, ``io.dataregion.default`` becomes ``group=io`` plus + ``name="dataregion.default"``. A registry without a dot, ``snapshot`` say, gets no group at all. + + Both values are spelled as the exporter spells them - quoted and escaped unless purely + alphanumeric - and matched literally. The properties of an MBean name are ordered + alphabetically, so the group always comes before the name but never right next to it. And + ``name`` sorts last of them all, so the pattern ends at the end of the line: without that + anchor ``myCache`` would also match the registry of ``myCacheV2``. + + A registry without a group needs the negative pattern as well - an ERE has no lookahead, and + the system view of the same name (``group=views,...,name=snapshot``) matches otherwise. + + The MBean name of a registry carries no pid, but it does carry the class loader hash, so it + may differ between two runs of a node. + + :param registry: Metric registry name. It must not contain a single or double quote, a backslash + or a question mark - see :data:`_UNSUPPORTED_REGISTRY_CHARS`. + :return: Tuple of the pattern and the negative pattern to pass to :meth:`JmxClient.find_mbean`. + """ + if _UNSUPPORTED_REGISTRY_CHARS.intersection(registry): + raise ValueError(f"A metric registry name with a quote, a backslash or '?' is not supported: {registry}") + + group, dot, name = registry.partition('.') + + if not dot: + return rf'.*name={_ere_escape(_object_name_value(registry))}\s*$', 'group=' + + return rf'.*group={_ere_escape(_object_name_value(group))},' \ + rf'.*name={_ere_escape(_object_name_value(name))}\s*$', None + def ignite_jmx_mixin(node, service): """ - Dynamically mixin JMX attributes to Ignite service node. + Dynamically mixin JMX attributes to Ignite service node. Called on every start of the node, + which is what hands a restarted node a JMX client of its new JVM. :param node: Ignite service node. :param service: Ignite service. """ setattr(node, 'pids', service.pids(node, service.main_java_class)) setattr(node, 'install_root', service.install_root) - base_cls = node.__class__ - base_cls_name = node.__class__.__name__ - node.__class__ = type(base_cls_name, (base_cls, IgniteJmxMixin), {}) + setattr(node, '_jmx_client', None) + + if not isinstance(node, IgniteJmxMixin): + base_cls = node.__class__ + base_cls_name = node.__class__.__name__ + node.__class__ = type(base_cls_name, (base_cls, IgniteJmxMixin), {}) class JmxMBean: @@ -53,6 +119,30 @@ def __getattr__(self, attr): """ return self.client.mbean_attribute(self.name, attr) + def value(self, attr, default=_NO_DEFAULT): + """ + Reads a single valued attribute - which is what a metric read almost always wants, + as opposed to the raw line iterator the attribute access itself returns. + + :param attr: Attribute name. + :param default: Value to return if the attribute reads nothing; StopIteration is raised if not passed. + :return: Attribute value, whitespace stripped. + """ + try: + return next(self.client.mbean_attribute(self.name, attr)).strip() + except StopIteration: + if default is _NO_DEFAULT: + raise + + return default + + def bool_value(self, attr): + """ + :param attr: Attribute name. + :return: Attribute value as a boolean; anything but "true" is False. + """ + return self.value(attr).lower() == "true" + def run(self, operation, params): """" Runs through JMX client MBean operation. @@ -66,11 +156,16 @@ def run(self, operation, params): class JmxClient: """JMX client, invokes jmxterm on node locally. """ - def __init__(self, node): + def __init__(self, node, pid=None): + """ + :param node: Node to run jmxterm on. + :param pid: JVM to connect to; the first of the node's pids if not passed. + """ self.node = node self.install_root = node.install_root - self.pid = node.pids[0] + self.pid = pid if pid is not None else node.pids[0] self.java_major = java_major_version(java_version(self.node)) + self._mbeans = {} @property def jmx_util_cmd(self): @@ -81,24 +176,34 @@ def jmx_util_cmd(self): return os.path.join(f"java {extra_flag} -jar {self.install_root}/jmxterm.jar -v silent -n") - @memoize def find_mbean(self, pattern, negative_pattern=None, domain='org.apache'): """ - Find mbean by specified pattern and domain on node. + Find mbean by specified pattern and domain on node. The lookup is cached by this client, which + is safe as long as the client does not outlive the JVM it was built for. :param pattern: MBean name pattern. :param negative_pattern: if passed used to filter out some MBeans :param domain: Domain of MBean :return: JmxMBean instance """ - cmd = "echo $'open %s\\n beans -d %s \\n close' | %s | grep -E -o '%s'" \ - % (self.pid, domain, self.jmx_util_cmd, pattern) + key = (pattern, negative_pattern, domain) + + if key not in self._mbeans: + cmd = "echo $'open %s\\n beans -d %s \\n close' | %s | grep -E -o '%s'" \ + % (self.pid, domain, self.jmx_util_cmd, pattern) - if negative_pattern: - cmd += " | grep -E -v '%s'" % negative_pattern + if negative_pattern: + cmd += " | grep -E -v '%s'" % negative_pattern - name = next(self.__run_cmd(cmd)).strip() + self._mbeans[key] = JmxMBean(self, next(self.__run_cmd(cmd)).strip()) - return JmxMBean(self, name) + return self._mbeans[key] + + def find_metric_registry(self, registry): + """ + :param registry: Metric registry name, e.g. ``cache.myCache`` - see :func:`metric_registry_pattern`. + :return: JmxMBean of the metric registry. + """ + return self.find_mbean(*metric_registry_pattern(registry)) def mbean_attribute(self, mbean, attr): """ @@ -188,31 +293,34 @@ def __find__(self, pattern): class IgniteJmxMixin: """ Mixin to IgniteService node, exposing useful properties, obtained from JMX. + + Everything here is read through the JMX client of the node's CURRENT JVM: a client holds the + pid and caches the MBean names of the JVM it was built for, so ignite_jmx_mixin() drops it on + every start of the node and the next access builds a new one. """ - @memoize def jmx_client(self): """ - :return: JmxClient instance. + :return: JmxClient instance of the running node. """ - # noinspection PyTypeChecker - return JmxClient(self) + if self._jmx_client is None: + # noinspection PyTypeChecker + self._jmx_client = JmxClient(self) + + return self._jmx_client - @memoize def node_id(self): """ :return: Local node id. """ - return next(self.kernal_mbean().LocalNodeId).strip() + return self.kernal_mbean().value("LocalNodeId") def discovery_info(self): """ :return: DiscoveryInfo instance. """ disco_mbean = self.disco_mbean() - crd = next(disco_mbean.Coordinator).strip() - local = next(disco_mbean.LocalNodeFormatted).strip() - return DiscoveryInfo(crd, local) + return DiscoveryInfo(disco_mbean.value("Coordinator"), disco_mbean.value("LocalNodeFormatted")) def kernal_mbean(self): """ @@ -220,12 +328,18 @@ def kernal_mbean(self): """ return self.jmx_client().find_mbean('.*group=Kernal.*name=IgniteKernal') - @memoize + def metric_registry_mbean(self, registry): + """ + :param registry: Metric registry name, e.g. ``cache.myCache`` or ``cacheGroups.myGroup``. + :return: MBean of the metric registry. + """ + return self.jmx_client().find_metric_registry(registry) + def disco_mbean(self): """ :return: DiscoverySpi MBean. """ - disco_spi = next(self.kernal_mbean().DiscoverySpiFormatted).strip() + disco_spi = self.kernal_mbean().value("DiscoverySpiFormatted") if 'ZookeeperDiscoverySpi' in disco_spi: return self.jmx_client().find_mbean('.*group=SPIs.*name=ZookeeperDiscoverySpi') diff --git a/modules/ducktests/tests/ignitetest/tests/dump_test.py b/modules/ducktests/tests/ignitetest/tests/dump_test.py index db58d5064441a..0a8bfcd4d6964 100644 --- a/modules/ducktests/tests/ignitetest/tests/dump_test.py +++ b/modules/ducktests/tests/ignitetest/tests/dump_test.py @@ -115,8 +115,8 @@ def get_data_region_size(ignite): data_region_size = {} for node in ignite.nodes: - mbean = node.jmx_client().find_mbean('.*group=io.*name="dataregion.default"') - data_region_size[node.consistent_id] = int(next(mbean.TotalUsedSize)) + mbean = node.metric_registry_mbean('io.dataregion.default') + data_region_size[node.consistent_id] = int(mbean.value("TotalUsedSize")) return { "data_region_size": data_region_size diff --git a/modules/ducktests/tests/ignitetest/tests/rebalance/util.py b/modules/ducktests/tests/ignitetest/tests/rebalance/util.py index 9a5351dd2388e..282a8f4ce786b 100644 --- a/modules/ducktests/tests/ignitetest/tests/rebalance/util.py +++ b/modules/ducktests/tests/ignitetest/tests/rebalance/util.py @@ -190,12 +190,12 @@ def get_rebalance_metrics(node, cache_group): :param cache_group: Cache group. :return: RebalanceMetrics instance. """ - mbean = node.jmx_client().find_mbean('.*group=cacheGroups.*name="%s"' % cache_group) - start_time = int(next(mbean.RebalancingStartTime)) - end_time = int(next(mbean.RebalancingEndTime)) + mbean = node.metric_registry_mbean(f'cacheGroups.{cache_group}') + start_time = int(mbean.value("RebalancingStartTime")) + end_time = int(mbean.value("RebalancingEndTime")) return RebalanceMetrics( - received_bytes=int(next(mbean.RebalancingReceivedBytes)), + received_bytes=int(mbean.value("RebalancingReceivedBytes")), start_time=start_time, end_time=end_time, duration=(end_time - start_time) if start_time != -1 and end_time != -1 else 0, From d34d2656d73d549284283bd1ef872d3a50825a5d Mon Sep 17 00:00:00 2001 From: Maksim Davydov Date: Thu, 17 Sep 2026 16:45:17 +0300 Subject: [PATCH 2/2] IGNITE-29049 [ducktests] Escape '[' in the ERE escaping character class An unescaped '[' right after the class opener made Python emit 'FutureWarning: Possible nested set' on every JMX metric lookup. --- modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py b/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py index 60bfb14a2e443..73ca0abdc46ec 100644 --- a/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py +++ b/modules/ducktests/tests/ignitetest/services/utils/jmx_utils.py @@ -46,7 +46,7 @@ def _ere_escape(value): Escapes a literal for a POSIX extended regular expression, which is what 'grep -E' reads - re.escape() escapes for the Python dialect instead. """ - return re.sub(r'([[\\.^$*+?(){|])', r'\\\1', value) + return re.sub(r'([\[\\.^$*+?(){|])', r'\\\1', value) def metric_registry_pattern(registry):