diff --git a/bz2163953-01-constraint-fixes.patch b/bz2163953-01-constraint-fixes.patch new file mode 100644 index 0000000..150a37e --- /dev/null +++ b/bz2163953-01-constraint-fixes.patch @@ -0,0 +1,1407 @@ +From cb31390fb04623250db814d08e84a0a1981916c7 Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Mon, 10 Jul 2023 14:40:07 +0200 +Subject: [PATCH 1/3] fix exporting location constraints with rules + +--- + pcs/cli/constraint/output/all.py | 62 ++++---- + pcs/cli/constraint/output/colocation.py | 46 ++++-- + pcs/cli/constraint/output/location.py | 20 ++- + pcs/cli/constraint/output/order.py | 15 +- + pcs/cli/constraint/output/set.py | 12 +- + pcs/cli/constraint/output/ticket.py | 9 +- + pcs/lib/cib/rule/cib_to_str.py | 21 ++- + pcs/rule.py | 14 ++ + pcs_test/Makefile.am | 2 + + pcs_test/resources/cib-all.xml | 8 +- + .../cib-rule-with-spaces-in-date.xml | 44 ++++++ + .../cib-unexportable-constraints.xml | 109 +++++++++++++ + pcs_test/resources/constraint-commands | 7 +- + pcs_test/resources/resource-commands | 7 +- + .../tier0/lib/cib/rule/test_cib_to_str.py | 93 +++++++++++ + pcs_test/tier0/lib/cib/rule/test_parser.py | 58 +++++++ + pcs_test/tier1/constraint/test_config.py | 147 ++++++++++++++++-- + pcs_test/tier1/legacy/test_constraints.py | 111 +++++++++++++ + pcs_test/tier1/legacy/test_rule.py | 24 +++ + pcs_test/tools/constraints_dto.py | 44 +++++- + 20 files changed, 755 insertions(+), 98 deletions(-) + create mode 100644 pcs_test/resources/cib-rule-with-spaces-in-date.xml + create mode 100644 pcs_test/resources/cib-unexportable-constraints.xml + +diff --git a/pcs/cli/constraint/output/all.py b/pcs/cli/constraint/output/all.py +index 829584bb..a173507c 100644 +--- a/pcs/cli/constraint/output/all.py ++++ b/pcs/cli/constraint/output/all.py +@@ -47,38 +47,44 @@ def constraints_to_cmd(constraints_dto: CibConstraintsDto) -> list[list[str]]: + for location_set_dto in constraints_dto.location_set: + warn( + "Location set constraint with id " +- f"'{location_set_dto.attributes.constraint_id}'configured but it's " +- "not supported by this command" ++ f"'{location_set_dto.attributes.constraint_id}' configured but it's " ++ "not supported by this command." ++ " Command for creating the constraint is omitted." + ) + location_cmds = [] + for location_dto in constraints_dto.location: + location_cmds.extend(location.plain_constraint_to_cmd(location_dto)) +- return ( +- location_cmds +- + [ +- colocation.plain_constraint_to_cmd(colocation_dto) +- for colocation_dto in constraints_dto.colocation +- ] +- + [ +- colocation.set_constraint_to_cmd(colocation_set_dto) +- for colocation_set_dto in constraints_dto.colocation_set +- ] +- + [ +- order.plain_constraint_to_cmd(order_dto) +- for order_dto in constraints_dto.order +- ] +- + [ +- order.set_constraint_to_cmd(order_set_dto) +- for order_set_dto in constraints_dto.order_set +- ] +- + [ +- ticket.plain_constraint_to_cmd(ticket_dto) +- for ticket_dto in constraints_dto.ticket +- ] +- + [ +- ticket.set_constraint_to_cmd(ticket_set_dto) +- for ticket_set_dto in constraints_dto.ticket_set +- ] ++ return list( ++ filter( ++ None, ++ ( ++ location_cmds ++ + [ ++ colocation.plain_constraint_to_cmd(colocation_dto) ++ for colocation_dto in constraints_dto.colocation ++ ] ++ + [ ++ colocation.set_constraint_to_cmd(colocation_set_dto) ++ for colocation_set_dto in constraints_dto.colocation_set ++ ] ++ + [ ++ order.plain_constraint_to_cmd(order_dto) ++ for order_dto in constraints_dto.order ++ ] ++ + [ ++ order.set_constraint_to_cmd(order_set_dto) ++ for order_set_dto in constraints_dto.order_set ++ ] ++ + [ ++ ticket.plain_constraint_to_cmd(ticket_dto) ++ for ticket_dto in constraints_dto.ticket ++ ] ++ + [ ++ ticket.set_constraint_to_cmd(ticket_set_dto) ++ for ticket_set_dto in constraints_dto.ticket_set ++ ] ++ ), ++ ) + ) + + +diff --git a/pcs/cli/constraint/output/colocation.py b/pcs/cli/constraint/output/colocation.py +index 9c8db33b..7a13ed27 100644 +--- a/pcs/cli/constraint/output/colocation.py ++++ b/pcs/cli/constraint/output/colocation.py +@@ -1,5 +1,8 @@ + from shlex import quote +-from typing import Iterable ++from typing import ( ++ Iterable, ++ Optional, ++) + + from pcs.cli.common.output import ( + INDENT_STEP, +@@ -120,13 +123,15 @@ def constraints_to_text( + def _attributes_to_cmd_pairs( + attributes_dto: CibConstraintColocationAttributesDto, + filter_out: StringCollection = tuple(), +-) -> list[tuple[str, str]]: ++) -> Optional[list[tuple[str, str]]]: + if attributes_dto.lifetime: + warn( + "Lifetime configuration detected in constraint " + f"'{attributes_dto.constraint_id}' but not supported by this " + "command." ++ " Command for creating the constraint is omitted." + ) ++ return None + unsupported_options = {"influence"} + result = [] + for pair in [("id", attributes_dto.constraint_id)] + _attributes_to_pairs( +@@ -136,8 +141,10 @@ def _attributes_to_cmd_pairs( + warn( + f"Option '{pair[0]}' detected in constraint " + f"'{attributes_dto.constraint_id}' but not supported by this " +- "command" ++ "command." ++ " Command for creating the constraint is omitted." + ) ++ return None + if pair[0] in filter_out: + continue + result.append(pair) +@@ -155,7 +162,17 @@ def plain_constraint_to_cmd( + "Resource instance(s) detected in constraint " + f"'{constraint_dto.attributes.constraint_id}' but not supported by " + "this command." ++ " Command for creating the constraint is omitted." + ) ++ return [] ++ if constraint_dto.node_attribute is not None: ++ warn( ++ "Option 'node_attribute' detected in constraint " ++ f"'{constraint_dto.attributes.constraint_id}' but not supported by " ++ "this command." ++ " Command for creating the constraint is omitted." ++ ) ++ return [] + result = [ + "pcs -- constraint colocation add {resource_role}{resource_id} with {with_resource_role}{with_resource_id}{score}".format( + resource_role=format_optional(constraint_dto.resource_role), +@@ -169,11 +186,12 @@ def plain_constraint_to_cmd( + ), + ) + ] +- params = pairs_to_cmd( +- _attributes_to_cmd_pairs( +- constraint_dto.attributes, filter_out=("score",) +- ) ++ pairs = _attributes_to_cmd_pairs( ++ constraint_dto.attributes, filter_out=("score",) + ) ++ if pairs is None: ++ return [] ++ params = pairs_to_cmd(pairs) + if params: + result.extend(indent([params], indent_step=INDENT_STEP)) + return result +@@ -184,12 +202,14 @@ def set_constraint_to_cmd( + ) -> list[str]: + result = ["pcs -- constraint colocation"] + for resource_set in constraint_dto.resource_sets: +- result.extend( +- indent( +- _set.resource_set_to_cmd(resource_set), indent_step=INDENT_STEP +- ) +- ) +- params = pairs_to_cmd(_attributes_to_cmd_pairs(constraint_dto.attributes)) ++ set_cmd_part = _set.resource_set_to_cmd(resource_set) ++ if not set_cmd_part: ++ return [] ++ result.extend(indent(set_cmd_part, indent_step=INDENT_STEP)) ++ pairs = _attributes_to_cmd_pairs(constraint_dto.attributes) ++ if pairs is None: ++ return [] ++ params = pairs_to_cmd(pairs) + if params: + result.extend(indent([f"setoptions {params}"], indent_step=INDENT_STEP)) + return result +diff --git a/pcs/cli/constraint/output/location.py b/pcs/cli/constraint/output/location.py +index 2713b7d0..25ac646a 100644 +--- a/pcs/cli/constraint/output/location.py ++++ b/pcs/cli/constraint/output/location.py +@@ -1,5 +1,5 @@ ++import shlex + from collections import defaultdict +-from shlex import quote + from typing import ( + Callable, + Iterable, +@@ -149,7 +149,7 @@ def _plain_constraint_get_resource_for_cmd( + resource = f"resource%{constraint_dto.resource_id}" + else: + resource = f"regexp%{constraint_dto.resource_pattern}" +- return quote(resource) ++ return shlex.quote(resource) + + + def _plain_constraint_to_cmd( +@@ -157,9 +157,9 @@ def _plain_constraint_to_cmd( + ) -> list[str]: + result = [ + "pcs -- constraint location add {id} {resource} {node} {score}".format( +- id=quote(constraint_dto.attributes.constraint_id), ++ id=shlex.quote(constraint_dto.attributes.constraint_id), + resource=_plain_constraint_get_resource_for_cmd(constraint_dto), +- node=quote(str(constraint_dto.attributes.node)), ++ node=shlex.quote(str(constraint_dto.attributes.node)), + score=constraint_dto.attributes.score, + ) + ] +@@ -185,12 +185,12 @@ def _rule_to_cmd_pairs(rule: CibRuleExpressionDto) -> list[tuple[str, str]]: + + + def _add_rule_cmd(constraint_id: str, rule: CibRuleExpressionDto) -> list[str]: +- result = [f"pcs -- constraint rule add {quote(constraint_id)}"] ++ result = [f"pcs -- constraint rule add {shlex.quote(constraint_id)}"] + result.extend( + indent( + [ + pairs_to_cmd([("id", rule.id)] + _rule_to_cmd_pairs(rule)), +- rule.as_string, ++ shlex.join(shlex.split(rule.as_string)), + ], + indent_step=INDENT_STEP, + ) +@@ -221,7 +221,7 @@ def _plain_constraint_rule_to_cmd( + + _attributes_to_pairs(constraint_dto.attributes) + + _rule_to_cmd_pairs(first_rule) + ), +- first_rule.as_string, ++ shlex.join(shlex.split(first_rule.as_string)), + ], + indent_step=INDENT_STEP, + ) +@@ -240,13 +240,17 @@ def plain_constraint_to_cmd( + "Lifetime configuration detected in constraint " + f"'{constraint_dto.attributes.constraint_id}' but not supported by " + "this command." ++ " Command for creating the constraint is omitted." + ) ++ return [] + if constraint_dto.role: + warn( +- f"Resource role '{constraint_dto.role}' detected in constraint " ++ f"Resource role detected in constraint " + f"'{constraint_dto.attributes.constraint_id}' but not supported by " + "this command." ++ " Command for creating the constraint is omitted." + ) ++ return [] + if constraint_dto.attributes.rules: + return _plain_constraint_rule_to_cmd(constraint_dto) + return [_plain_constraint_to_cmd(constraint_dto)] +diff --git a/pcs/cli/constraint/output/order.py b/pcs/cli/constraint/output/order.py +index f407270d..53fe546a 100644 +--- a/pcs/cli/constraint/output/order.py ++++ b/pcs/cli/constraint/output/order.py +@@ -127,7 +127,9 @@ def plain_constraint_to_cmd( + "Resource instance(s) detected in constraint " + f"'{constraint_dto.attributes.constraint_id}' but not supported by " + "this command." ++ " Command for creating the constraint is omitted." + ) ++ return [] + result = [ + "pcs -- constraint order {first_action}{first_resource_id} then {then_action}{then_resource_id}".format( + first_action=format_optional(constraint_dto.first_action), +@@ -147,11 +149,10 @@ def set_constraint_to_cmd( + ) -> list[str]: + result = ["pcs -- constraint order"] + for resource_set in constraint_dto.resource_sets: +- result.extend( +- indent( +- _set.resource_set_to_cmd(resource_set), indent_step=INDENT_STEP +- ) +- ) ++ set_cmd_part = _set.resource_set_to_cmd(resource_set) ++ if not set_cmd_part: ++ return [] ++ result.extend(indent(set_cmd_part, indent_step=INDENT_STEP)) + pairs = [] + for pair in _attributes_to_cmd_pairs(constraint_dto.attributes): + # this list is based on pcs.lib.cib.constraint.order.ATTRIB +@@ -159,8 +160,10 @@ def set_constraint_to_cmd( + warn( + f"Option '{pair[0]}' detected in constraint " + f"'{constraint_dto.attributes.constraint_id}' but not " +- "supported by this command" ++ "supported by this command." ++ " Command for creating the constraint is omitted." + ) ++ return [] + pairs.append(pair) + if pairs: + result.extend( +diff --git a/pcs/cli/constraint/output/set.py b/pcs/cli/constraint/output/set.py +index 3b1fa31a..5395ebf7 100644 +--- a/pcs/cli/constraint/output/set.py ++++ b/pcs/cli/constraint/output/set.py +@@ -1,4 +1,7 @@ +-from typing import Sequence ++from typing import ( ++ Optional, ++ Sequence, ++) + + from pcs.cli.common.output import ( + INDENT_STEP, +@@ -81,7 +84,7 @@ def set_constraint_to_text( + return result + + +-def resource_set_to_cmd(resource_set: CibResourceSetDto) -> list[str]: ++def resource_set_to_cmd(resource_set: CibResourceSetDto) -> Optional[list[str]]: + filtered_pairs = [] + for pair in _resource_set_options_to_pairs(resource_set): + # this list is based on pcs.lib.cib.constraint.resource_set._ATTRIBUTES +@@ -89,9 +92,10 @@ def resource_set_to_cmd(resource_set: CibResourceSetDto) -> list[str]: + warn( + f"Option '{pair[0]}' detected in resource set " + f"'{resource_set.set_id}' but not " +- "supported by this command" ++ "supported by this command." ++ " Command for creating the constraint is omitted." + ) +- continue ++ return None + filtered_pairs.append(pair) + + return [ +diff --git a/pcs/cli/constraint/output/ticket.py b/pcs/cli/constraint/output/ticket.py +index e047226c..d83e65b8 100644 +--- a/pcs/cli/constraint/output/ticket.py ++++ b/pcs/cli/constraint/output/ticket.py +@@ -121,11 +121,10 @@ def set_constraint_to_cmd( + ) -> list[str]: + result = ["pcs -- constraint ticket"] + for resource_set in constraint_dto.resource_sets: +- result.extend( +- indent( +- _set.resource_set_to_cmd(resource_set), indent_step=INDENT_STEP +- ) +- ) ++ set_cmd_part = _set.resource_set_to_cmd(resource_set) ++ if not set_cmd_part: ++ return [] ++ result.extend(indent(set_cmd_part, indent_step=INDENT_STEP)) + params = pairs_to_cmd( + _attributes_to_cmd_pairs(constraint_dto.attributes) + + [("ticket", constraint_dto.attributes.ticket)] +diff --git a/pcs/lib/cib/rule/cib_to_str.py b/pcs/lib/cib/rule/cib_to_str.py +index b196d8f6..29b67a8a 100644 +--- a/pcs/lib/cib/rule/cib_to_str.py ++++ b/pcs/lib/cib/rule/cib_to_str.py +@@ -1,3 +1,4 @@ ++import re + from typing import ( + Dict, + cast, +@@ -17,6 +18,8 @@ class RuleToStr: + Export a rule XML element to a string which creates the same element + """ + ++ _date_separators_re = re.compile(r"\s*([TZ:.+-])\s*") ++ + def __init__(self) -> None: + # The cache prevents evaluating subtrees repeatedly. + self._cache: Dict[str, str] = {} +@@ -43,6 +46,16 @@ class RuleToStr: + ) + ) + ++ @staticmethod ++ def _date_to_str(date: str) -> str: ++ # remove spaces around separators ++ result = re.sub(RuleToStr._date_separators_re, r"\1", date) ++ # if there are any spaces left, replace the first one with T ++ result = re.sub(r"\s+", "T", result, count=1) ++ # keep all other spaces in place ++ # the date wouldn't be valid, but there is nothing more we can do ++ return result ++ + def _rule_to_str(self, rule_el: _Element) -> str: + # "and" is a documented pacemaker default + # https://clusterlabs.org/pacemaker/doc/en-US/Pacemaker/2.0/html-single/Pacemaker_Explained/index.html#_rule_properties +@@ -95,10 +108,10 @@ class RuleToStr: + string_parts.extend(["date", "in_range"]) + # CIB schema allows "start" + "duration" or optional "start" + "end" + if "start" in expr_el.attrib: +- string_parts.append(str(expr_el.get("start", ""))) ++ string_parts.append(self._date_to_str(expr_el.get("start", ""))) + string_parts.append("to") + if "end" in expr_el.attrib: +- string_parts.append(str(expr_el.get("end", ""))) ++ string_parts.append(self._date_to_str(expr_el.get("end", ""))) + if duration is not None: + string_parts.append("duration") + string_parts.append(self._attrs_to_str(duration)) +@@ -107,9 +120,9 @@ class RuleToStr: + # operation=="lt" + "end" + string_parts.extend(["date", str(expr_el.get("operation", ""))]) + if "start" in expr_el.attrib: +- string_parts.append(str(expr_el.get("start", ""))) ++ string_parts.append(self._date_to_str(expr_el.get("start", ""))) + if "end" in expr_el.attrib: +- string_parts.append(str(expr_el.get("end", ""))) ++ string_parts.append(self._date_to_str(expr_el.get("end", ""))) + return " ".join(string_parts) + + def _op_expr_to_str(self, expr_el: _Element) -> str: +diff --git a/pcs/rule.py b/pcs/rule.py +index 7c0e1400..172f1e1e 100644 +--- a/pcs/rule.py ++++ b/pcs/rule.py +@@ -7,6 +7,7 @@ from typing import ( + ) + + from pcs import utils ++from pcs.cli.reports.output import deprecation_warning + from pcs.common import ( + const, + pacemaker, +@@ -893,6 +894,17 @@ class RuleParser(Parser): + class CibBuilder: + def __init__(self, cib_schema_version): + self.cib_schema_version = cib_schema_version ++ self.space_deprecation_printed = False ++ ++ # deprecated since pcs-0.11.7 ++ def date_space_deprecation(self, date): ++ if self.space_deprecation_printed or " " not in date: ++ return ++ self.space_deprecation_printed = True ++ deprecation_warning( ++ "Using spaces in date values is deprecated and will be removed. " ++ "Use 'T' as a delimiter between date and time." ++ ) + + def build(self, dom_element, syntactic_tree, rule_id=None): + dom_rule = self.add_element( +@@ -978,6 +990,7 @@ class CibBuilder: + "'%s' is not an ISO 8601 date" + % syntactic_tree.children[1].value + ) ++ self.date_space_deprecation(syntactic_tree.children[1].value) + dom_expression.setAttribute("operation", syntactic_tree.symbol_id) + if syntactic_tree.symbol_id == "gt": + dom_expression.setAttribute( +@@ -1008,6 +1021,7 @@ class CibBuilder: + "'%s' is not an ISO 8601 date" + % syntactic_tree.children[2].value + ) ++ self.date_space_deprecation(syntactic_tree.children[2].value) + dom_expression.setAttribute( + "end", syntactic_tree.children[2].value + ) +diff --git a/pcs_test/Makefile.am b/pcs_test/Makefile.am +index 738f6622..64ef1d9e 100644 +--- a/pcs_test/Makefile.am ++++ b/pcs_test/Makefile.am +@@ -20,7 +20,9 @@ EXTRA_DIST = \ + resources/cib-property.xml \ + resources/cib-resources.xml \ + resources/cib-all.xml \ ++ resources/cib-rule-with-spaces-in-date.xml \ + resources/cib-tags.xml \ ++ resources/cib-unexportable-constraints.xml \ + resources/controld_metadata.xml \ + resources/corosync-3nodes.conf \ + resources/corosync-3nodes-qdevice.conf \ +diff --git a/pcs_test/resources/cib-all.xml b/pcs_test/resources/cib-all.xml +index a44a546b..b738d7e6 100644 +--- a/pcs_test/resources/cib-all.xml ++++ b/pcs_test/resources/cib-all.xml +@@ -135,11 +135,13 @@ + + + +- +- ++ ++ ++ + +- ++ + ++ + + + +diff --git a/pcs_test/resources/cib-rule-with-spaces-in-date.xml b/pcs_test/resources/cib-rule-with-spaces-in-date.xml +new file mode 100644 +index 00000000..a68a1287 +--- /dev/null ++++ b/pcs_test/resources/cib-rule-with-spaces-in-date.xml +@@ -0,0 +1,44 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/cib-unexportable-constraints.xml b/pcs_test/resources/cib-unexportable-constraints.xml +new file mode 100644 +index 00000000..642bad96 +--- /dev/null ++++ b/pcs_test/resources/cib-unexportable-constraints.xml +@@ -0,0 +1,109 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/constraint-commands b/pcs_test/resources/constraint-commands +index 096bdec0..759e7146 100644 +--- a/pcs_test/resources/constraint-commands ++++ b/pcs_test/resources/constraint-commands +@@ -5,15 +5,12 @@ pcs -- constraint location add location-R7-localhost-INFINITY resource%R7 localh + resource-discovery=always; + pcs -- constraint location add location-G2-localhost-INFINITY resource%G2 localhost INFINITY; + pcs -- constraint location add location-R-localhost-INFINITY 'regexp%R*' localhost INFINITY; +-pcs -- constraint location resource%B2 rule \ +- id=loc_constr_with_expired_rule-rule constraint-id=loc_constr_with_expired_rule score=500 \ +- date lt 2000-01-01; + pcs -- constraint location resource%R6-clone rule \ + id=loc_constr_with_not_expired_rule-rule constraint-id=loc_constr_with_not_expired_rule role=Unpromoted score=500 \ +- date gt 2000-01-01; ++ '#uname' eq node1 and date gt 2000-01-01; + pcs -- constraint rule add loc_constr_with_not_expired_rule \ + id=loc_constr_with_not_expired_rule-rule-1 role=Promoted score-attribute=test-attr \ +- date gt 2010-12-31; ++ date gt 2010-12-31 and '#uname' eq node1; + pcs -- constraint colocation add Promoted G1-clone with Stopped R6-clone -100 \ + id=colocation-G1-clone-R6-clone--100; + pcs -- constraint colocation \ +diff --git a/pcs_test/resources/resource-commands b/pcs_test/resources/resource-commands +index 80775e7e..296d279c 100644 +--- a/pcs_test/resources/resource-commands ++++ b/pcs_test/resources/resource-commands +@@ -15,14 +15,15 @@ pcs resource bundle create B2 \ + container docker \ + image=pcs:test; + pcs resource create R1 ocf:pacemaker:Dummy --no-default-ops bundle B2 --force; +-pcs resource create R2 ocf:pacemaker:Dummy --no-default-ops; +-pcs resource create R3 ocf:pacemaker:Dummy --no-default-ops; +-pcs resource create R4 ocf:pacemaker:Dummy --no-default-ops; ++pcs resource create R2 ocf:pacemaker:Stateful --no-default-ops; ++pcs resource create R3 ocf:pacemaker:Stateful --no-default-ops; ++pcs resource create R4 ocf:pacemaker:Stateful --no-default-ops; + pcs resource create R5 ocf:pacemaker:Dummy --no-default-ops; + pcs resource create R6 ocf:pacemaker:Dummy; + pcs resource create R7 ocf:pacemaker:Dummy --force \ + fake=looool envfile=/dev/null \ + op custom_action interval=10s OCF_CHECK_LEVEL=2 \ ++ migrate_to interval=0s id=R7-migrate_to-interval-0s timeout=20s enabled=0 record-pending=0 \ + meta m1=value1 meta2=valueofmeta2isthisverylongstring "anotherone=something'\"special" m10=value1 meta20=valueofmeta2isthisverylongstring "another one0=a + b = c"; + pcs stonith create S1 fence_kdump nodename=testnodename; + pcs stonith create S2 fence_kdump; +diff --git a/pcs_test/tier0/lib/cib/rule/test_cib_to_str.py b/pcs_test/tier0/lib/cib/rule/test_cib_to_str.py +index eaef7d4b..4d93628a 100644 +--- a/pcs_test/tier0/lib/cib/rule/test_cib_to_str.py ++++ b/pcs_test/tier0/lib/cib/rule/test_cib_to_str.py +@@ -5,3 +5,96 @@ + # pcs_test/tier0/lib/commands/test_cib_options.py. + # Therefore we don't duplicate those here. However, if there's a need to write + # specific tests here, feel free to do so. ++ ++ ++from unittest import TestCase ++ ++from pcs.lib.cib.rule.cib_to_str import RuleToStr ++ ++ ++class IsoToStr(TestCase): ++ # pylint: disable=protected-access ++ def test_no_change(self): ++ self.assertEqual(RuleToStr._date_to_str("2023-06"), "2023-06") ++ self.assertEqual(RuleToStr._date_to_str("202306"), "202306") ++ self.assertEqual(RuleToStr._date_to_str("2023-06-30"), "2023-06-30") ++ self.assertEqual(RuleToStr._date_to_str("20230630"), "20230630") ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30T16:30"), "2023-06-30T16:30" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630T1630"), "20230630T1630" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30T16:30Z"), "2023-06-30T16:30Z" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630T1630+2"), "20230630T1630+2" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30T16:30:40+2:00"), ++ "2023-06-30T16:30:40+2:00", ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630T1630+02:00"), "20230630T1630+02:00" ++ ) ++ ++ def test_remove_spaces(self): ++ self.assertEqual(RuleToStr._date_to_str("- 2023"), "-2023") ++ self.assertEqual(RuleToStr._date_to_str("+ 2023"), "+2023") ++ self.assertEqual(RuleToStr._date_to_str("2023- 06"), "2023-06") ++ self.assertEqual(RuleToStr._date_to_str("2023 -06- 30"), "2023-06-30") ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30 T16:30"), "2023-06-30T16:30" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630T 1630"), "20230630T1630" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30 T 16:30 Z"), "2023-06-30T16:30Z" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630 T 1630 + 2"), "20230630T1630+2" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str( ++ "2023 - 06 - 30 T 16 : 30 : 40 + 2: 00" ++ ), ++ "2023-06-30T16:30:40+2:00", ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630 T 1630+ 02:00"), ++ "20230630T1630+02:00", ++ ) ++ ++ def test_add_time_separator(self): ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30 16:30"), "2023-06-30T16:30" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630 1630"), "20230630T1630" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30 16:30 Z"), "2023-06-30T16:30Z" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630 1630 + 2"), "20230630T1630+2" ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("2023 - 06 - 30 16 : 30 : 40 + 2: 00"), ++ "2023-06-30T16:30:40+2:00", ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("20230630 1630+ 02:00"), ++ "20230630T1630+02:00", ++ ) ++ ++ def test_extra_spaces(self): ++ self.assertEqual( ++ RuleToStr._date_to_str("2023-06-30 16:30:40 +2 00"), ++ "2023-06-30T16:30:40+2 00", ++ ) ++ self.assertEqual( ++ RuleToStr._date_to_str("2023 06 30 16 30 +02"), ++ "2023T06 30 16 30+02", ++ ) +diff --git a/pcs_test/tier0/lib/cib/rule/test_parser.py b/pcs_test/tier0/lib/cib/rule/test_parser.py +index fd089ec8..37ae52f1 100644 +--- a/pcs_test/tier0/lib/cib/rule/test_parser.py ++++ b/pcs_test/tier0/lib/cib/rule/test_parser.py +@@ -512,6 +512,14 @@ class Parser(TestCase): + DateUnaryExpr operator=GT date=2014-06-26""" + ), + ), ++ ( ++ "date gt 2014-06-26T12:00:00", ++ dedent( ++ """\ ++ BoolExpr AND ++ DateUnaryExpr operator=GT date=2014-06-26T12:00:00""" ++ ), ++ ), + ( + "date lt 2014-06-26", + dedent( +@@ -520,6 +528,14 @@ class Parser(TestCase): + DateUnaryExpr operator=LT date=2014-06-26""" + ), + ), ++ ( ++ "date lt 2014-06-26T12:00:00", ++ dedent( ++ """\ ++ BoolExpr AND ++ DateUnaryExpr operator=LT date=2014-06-26T12:00:00""" ++ ), ++ ), + ( + "date in_range 2014-06-26 to 2014-07-26", + dedent( +@@ -528,6 +544,14 @@ class Parser(TestCase): + DateInRangeExpr date_start=2014-06-26 date_end=2014-07-26""" + ), + ), ++ ( ++ "date in_range 2014-06-26T12:00:00 to 2014-07-26T13:00:00", ++ dedent( ++ """\ ++ BoolExpr AND ++ DateInRangeExpr date_start=2014-06-26T12:00:00 date_end=2014-07-26T13:00:00""" ++ ), ++ ), + ( + "date in_range to 2014-07-26", + dedent( +@@ -536,6 +560,14 @@ class Parser(TestCase): + DateInRangeExpr date_end=2014-07-26""" + ), + ), ++ ( ++ "date in_range to 2014-07-26T12:00:00", ++ dedent( ++ """\ ++ BoolExpr AND ++ DateInRangeExpr date_end=2014-07-26T12:00:00""" ++ ), ++ ), + ( + "date in_range 2014-06-26 to duration years=1", + dedent( +@@ -546,6 +578,16 @@ class Parser(TestCase): + )""" + ), + ), ++ ( ++ "date in_range 2014-06-26T12:00:00 to duration years=1", ++ dedent( ++ """\ ++ BoolExpr AND ++ DateInRangeExpr date_start=2014-06-26T12:00:00 duration_parts=( ++ years=1 ++ )""" ++ ), ++ ), + ( + "date in_range 2014-06-26 to duration a=1 b=2 a=3", + dedent( +@@ -876,6 +918,22 @@ class Parser(TestCase): + "#uname in_range 2014-06-26 to 2014-07-26", + (1, 8, 7, "Expected 'eq'"), + ), ++ ( ++ "date gt 2014-06-24 12:00:00", ++ (1, 20, 19, "Expected end of text"), ++ ), ++ ( ++ "date lt 2014-06-24 12:00:00", ++ (1, 20, 19, "Expected end of text"), ++ ), ++ ( ++ "date in_range 2014-06-26 12:00:00 to 2014-07-26", ++ (1, 15, 14, "Expected 'to'"), ++ ), ++ ( ++ "date in_range 2014-06-26 to 2014-07-26 12:00:00", ++ (1, 40, 39, "Expected end of text"), ++ ), + # braces + ("(#uname)", (1, 8, 7, "Expected 'eq'")), + ("(", (1, 2, 1, "Expected 'date'")), +diff --git a/pcs_test/tier1/constraint/test_config.py b/pcs_test/tier1/constraint/test_config.py +index 27aed9c0..525aac25 100644 +--- a/pcs_test/tier1/constraint/test_config.py ++++ b/pcs_test/tier1/constraint/test_config.py +@@ -72,18 +72,19 @@ class ConstraintConfigJson(TestCase): + + + class ConstraintConfigCmdMixin: +- # pylint: disable=invalid-name ++ orig_cib_file_path = get_test_resource("cib-all.xml") ++ + def setUp(self): +- orig_cib_file_path = get_test_resource("cib-all.xml") ++ # pylint: disable=invalid-name + self.new_cib_file = get_tmp_file(self._get_tmp_file_name()) +- self.pcs_runner_orig = PcsRunner(cib_file=orig_cib_file_path) ++ self.pcs_runner_orig = PcsRunner(cib_file=self.orig_cib_file_path) + self.pcs_runner_new = PcsRunner(cib_file=self.new_cib_file.name) + write_data_to_tmpfile( + fixture_cib.modify_cib_file( + get_test_resource("cib-empty.xml"), + resources=etree_to_str( + get_resources( +- XmlManipulation.from_file(orig_cib_file_path).tree ++ XmlManipulation.from_file(self.orig_cib_file_path).tree + ) + ), + ), +@@ -92,6 +93,7 @@ class ConstraintConfigCmdMixin: + self.maxDiff = None + + def tearDown(self): ++ # pylint: disable=invalid-name + self.new_cib_file.close() + + def _get_as_json(self, runner, use_all): +@@ -141,6 +143,115 @@ class ConstraintConfigCmd(ConstraintConfigCmdMixin, TestCase): + return "tier1_constraint_test_config_cib.xml" + + ++class ConstraintConfigCmdSpaceInDate(ConstraintConfigCmdMixin, TestCase): ++ # This class tests that pcs exports dates from location rules constraint ++ # with spaces replaced by T in pcs commands, so that they can be run and ++ # processed by pcs correctly. ++ orig_cib_file_path = get_test_resource("cib-rule-with-spaces-in-date.xml") ++ ++ @staticmethod ++ def _get_tmp_file_name(): ++ return "tier1_constraint_test_config_cib_date_space.xml" ++ ++ @staticmethod ++ def _replace(struct, search_replace): ++ if isinstance(struct, dict): ++ for key, val in struct.items(): ++ struct[key] = ConstraintConfigCmdSpaceInDate._replace( ++ val, search_replace ++ ) ++ return struct ++ if isinstance(struct, list): ++ return [ ++ ConstraintConfigCmdSpaceInDate._replace(val, search_replace) ++ for val in struct ++ ] ++ for search, replace in search_replace: ++ if struct == search: ++ return replace ++ return struct ++ ++ def _get_as_json(self, runner, use_all): ++ data = super()._get_as_json(runner, use_all) ++ data = self._replace( ++ data, ++ [ ++ ("2023-01-01 12:00", "2023-01-01T12:00"), ++ ("2023-12-31 12:00", "2023-12-31T12:00"), ++ ], ++ ) ++ return data ++ ++ def test_commands(self): ++ stdout, stderr, retval = self.pcs_runner_orig.run( ++ ["constraint", "config", "--output-format=cmd"] ++ ) ++ self.assertEqual(retval, 0) ++ self.assertEqual(stderr, "") ++ self.assertEqual( ++ stdout, ++ ( ++ "pcs -- constraint location resource%R1 rule \\\n" ++ " id=location-R1-rule constraint-id=location-R1 score=INFINITY \\\n" ++ " '#uname' eq node1 and date gt 2023-01-01T12:00 and " ++ "date lt 2023-12-31T12:00 and date in_range 2023-01-01T12:00 " ++ "to 2023-12-31T12:00;\n" ++ "pcs -- constraint rule add location-R1 \\\n" ++ " id=location-R1-rule-1 score=INFINITY \\\n" ++ " '#uname' eq node1 and date gt 2023-01-01T12:00 and " ++ "date lt 2023-12-31T12:00 and date in_range 2023-01-01T12:00 " ++ "to 2023-12-31T12:00\n" ++ ), ++ ) ++ ++ ++class ConstraintConfigCmdUnsupported(TestCase): ++ def setUp(self): ++ self.maxDiff = None ++ self.pcs_runner = PcsRunner( ++ cib_file=get_test_resource("cib-unexportable-constraints.xml"), ++ ) ++ ++ def test_dont_export_unsupported_constraints(self): ++ stdout, stderr, retval = self.pcs_runner.run( ++ ["constraint", "config", "--output-format=cmd"] ++ ) ++ self.assertEqual(retval, 0) ++ sufix = "not supported by this command. Command for creating the constraint is omitted.\n" ++ self.assertEqual( ++ stderr, ++ ( ++ f"Warning: Location set constraint with id 'location-set' configured but it's {sufix}" ++ f"Warning: Resource role detected in constraint 'location-role' but {sufix}" ++ f"Warning: Lifetime configuration detected in constraint 'location-lifetime' but {sufix}" ++ f"Warning: Option 'influence' detected in constraint 'colocation-influence' but {sufix}" ++ f"Warning: Lifetime configuration detected in constraint 'colocation-lifetime' but {sufix}" ++ f"Warning: Option 'node_attribute' detected in constraint 'colocation-node-attribute' but {sufix}" ++ f"Warning: Option 'ordering' detected in resource set 'colocation-set-ordering-set' but {sufix}" ++ f"Warning: Option 'require-all' detected in constraint 'order-set-require-all' but {sufix}" ++ f"Warning: Option 'ordering' detected in resource set 'order-set-ordering-set' but {sufix}" ++ ), ++ ) ++ self.assertEqual( ++ stdout, ++ ( ++ "pcs -- constraint location add location-OK resource%R1 node1 INFINITY;\n" ++ "pcs -- constraint colocation add R1 with R3 INFINITY \\\n" ++ " id=colocation-OK;\n" ++ "pcs -- constraint colocation \\\n" ++ " set R1 R3 \\\n" ++ " setoptions id=colocation-set-OK;\n" ++ "pcs -- constraint order start R1 then start R3 \\\n" ++ " id=order-OK;\n" ++ "pcs -- constraint order start R1 then start R3 \\\n" ++ " id=order-lifetime;\n" ++ "pcs -- constraint order \\\n" ++ " set R1 R3 \\\n" ++ " setoptions id=order-set-OK\n" ++ ), ++ ) ++ ++ + class ConstraintConfigText(TestCase): + def setUp(self): + self.maxDiff = None +@@ -161,10 +272,12 @@ class ConstraintConfigText(TestCase): + resource pattern 'R*' prefers node 'localhost' with score INFINITY + resource 'R6-clone' + Rules: +- Rule: role=Unpromoted score=500 ++ Rule: boolean-op=and role=Unpromoted score=500 ++ Expression: #uname eq node1 + Expression: date gt 2000-01-01 +- Rule: role=Promoted score-attribute=test-attr ++ Rule: boolean-op=and role=Promoted score-attribute=test-attr + Expression: date gt 2010-12-31 ++ Expression: #uname eq node1 + Colocation Constraints: + Promoted resource 'G1-clone' with Stopped resource 'R6-clone' + score=-100 +@@ -225,10 +338,12 @@ class ConstraintConfigText(TestCase): + Expression: date lt 2000-01-01 + resource 'R6-clone' + Rules: +- Rule: role=Unpromoted score=500 ++ Rule: boolean-op=and role=Unpromoted score=500 ++ Expression: #uname eq node1 + Expression: date gt 2000-01-01 +- Rule: role=Promoted score-attribute=test-attr ++ Rule: boolean-op=and role=Promoted score-attribute=test-attr + Expression: date gt 2010-12-31 ++ Expression: #uname eq node1 + Colocation Constraints: + Promoted resource 'G1-clone' with Stopped resource 'R6-clone' + score=-100 +@@ -285,10 +400,12 @@ class ConstraintConfigText(TestCase): + resource pattern 'R*' prefers node 'localhost' with score INFINITY (id: location-R-localhost-INFINITY) + resource 'R6-clone' (id: loc_constr_with_not_expired_rule) + Rules: +- Rule: role=Unpromoted score=500 (id: loc_constr_with_not_expired_rule-rule) +- Expression: date gt 2000-01-01 (id: loc_constr_with_not_expired_rule-rule-expr) +- Rule: role=Promoted score-attribute=test-attr (id: loc_constr_with_not_expired_rule-rule-1) ++ Rule: boolean-op=and role=Unpromoted score=500 (id: loc_constr_with_not_expired_rule-rule) ++ Expression: #uname eq node1 (id: loc_constr_with_not_expired_rule-rule-expr) ++ Expression: date gt 2000-01-01 (id: loc_constr_with_not_expired_rule-rule-expr-1) ++ Rule: boolean-op=and role=Promoted score-attribute=test-attr (id: loc_constr_with_not_expired_rule-rule-1) + Expression: date gt 2010-12-31 (id: loc_constr_with_not_expired_rule-rule-1-expr) ++ Expression: #uname eq node1 (id: loc_constr_with_not_expired_rule-rule-1-expr-1) + Colocation Constraints: + Promoted resource 'G1-clone' with Stopped resource 'R6-clone' (id: colocation-G1-clone-R6-clone--100) + score=-100 +@@ -349,10 +466,12 @@ class ConstraintConfigText(TestCase): + Expression: date lt 2000-01-01 (id: loc_constr_with_expired_rule-rule-expr) + resource 'R6-clone' (id: loc_constr_with_not_expired_rule) + Rules: +- Rule: role=Unpromoted score=500 (id: loc_constr_with_not_expired_rule-rule) +- Expression: date gt 2000-01-01 (id: loc_constr_with_not_expired_rule-rule-expr) +- Rule: role=Promoted score-attribute=test-attr (id: loc_constr_with_not_expired_rule-rule-1) ++ Rule: boolean-op=and role=Unpromoted score=500 (id: loc_constr_with_not_expired_rule-rule) ++ Expression: #uname eq node1 (id: loc_constr_with_not_expired_rule-rule-expr) ++ Expression: date gt 2000-01-01 (id: loc_constr_with_not_expired_rule-rule-expr-1) ++ Rule: boolean-op=and role=Promoted score-attribute=test-attr (id: loc_constr_with_not_expired_rule-rule-1) + Expression: date gt 2010-12-31 (id: loc_constr_with_not_expired_rule-rule-1-expr) ++ Expression: #uname eq node1 (id: loc_constr_with_not_expired_rule-rule-1-expr-1) + Colocation Constraints: + Promoted resource 'G1-clone' with Stopped resource 'R6-clone' (id: colocation-G1-clone-R6-clone--100) + score=-100 +diff --git a/pcs_test/tier1/legacy/test_constraints.py b/pcs_test/tier1/legacy/test_constraints.py +index e5e8bd27..7e629a89 100644 +--- a/pcs_test/tier1/legacy/test_constraints.py ++++ b/pcs_test/tier1/legacy/test_constraints.py +@@ -202,6 +202,117 @@ class ConstraintTest(unittest.TestCase, AssertPcsMixin): + ), + ) + ++ def test_constraint_rules_space_deprecated(self): ++ self.fixture_resources() ++ message = ( ++ "Deprecation Warning: Using spaces in date values is deprecated and " ++ "will be removed. Use 'T' as a delimiter between date and time.\n" ++ ) ++ self.assert_pcs_success( ++ "constraint location D1 rule".split() ++ + [ ++ "date", ++ "gt", ++ "2023-01-01 12:00 +3:00", ++ "and", ++ "date", ++ "lt", ++ "2023-12-31 12:00 -10:30", ++ "and", ++ "date", ++ "in_range", ++ "2023-01-01 12:00", ++ "to", ++ "2023-12-31 12:00", ++ ], ++ stderr_full=message, ++ ) ++ self.assert_pcs_success( ++ "constraint location D1 rule".split() ++ + ["date", "gt", "2023-01-01 12:00"], ++ stderr_full=message, ++ ) ++ self.assert_pcs_success( ++ "constraint location D1 rule".split() ++ + ["date", "lt", "2023-12-31 12:00"], ++ stderr_full=message, ++ ) ++ self.assert_pcs_success( ++ "constraint location D1 rule".split() ++ + [ ++ "date", ++ "in_range", ++ "2023-01-01 12:00", ++ "to", ++ "2023-12-31T12:00", ++ ], ++ stderr_full=message, ++ ) ++ self.assert_pcs_success( ++ "constraint location D1 rule".split() ++ + [ ++ "date", ++ "in_range", ++ "2023-01-01T12:00", ++ "to", ++ "2023-12-31 12:00", ++ ], ++ stderr_full=message, ++ ) ++ # when exporting the rules, spaces are replaced by T ++ self.assert_pcs_success( ++ "constraint config".split(), ++ dedent( ++ """\ ++ Location Constraints: ++ resource 'D1' ++ Rules: ++ Rule: boolean-op=and score=INFINITY ++ Expression: date gt 2023-01-01T12:00+3:00 ++ Expression: date lt 2023-12-31T12:00-10:30 ++ Expression: date in_range 2023-01-01T12:00 to 2023-12-31T12:00 ++ resource 'D1' ++ Rules: ++ Rule: score=INFINITY ++ Expression: date gt 2023-01-01T12:00 ++ resource 'D1' ++ Rules: ++ Rule: score=INFINITY ++ Expression: date lt 2023-12-31T12:00 ++ resource 'D1' ++ Rules: ++ Rule: score=INFINITY ++ Expression: date in_range 2023-01-01T12:00 to 2023-12-31T12:00 ++ resource 'D1' ++ Rules: ++ Rule: score=INFINITY ++ Expression: date in_range 2023-01-01T12:00 to 2023-12-31T12:00 ++ """ ++ ), ++ ) ++ self.assert_pcs_success( ++ "constraint config --output-format=cmd".split(), ++ dedent( ++ """\ ++ pcs -- constraint location resource%D1 rule \\ ++ id=location-D1-rule constraint-id=location-D1 score=INFINITY \\ ++ date gt 2023-01-01T12:00+3:00 and date lt 2023-12-31T12:00-10:30 and date in_range 2023-01-01T12:00 to 2023-12-31T12:00; ++ pcs -- constraint location resource%D1 rule \\ ++ id=location-D1-1-rule constraint-id=location-D1-1 score=INFINITY \\ ++ date gt 2023-01-01T12:00; ++ pcs -- constraint location resource%D1 rule \\ ++ id=location-D1-2-rule constraint-id=location-D1-2 score=INFINITY \\ ++ date lt 2023-12-31T12:00; ++ pcs -- constraint location resource%D1 rule \\ ++ id=location-D1-3-rule constraint-id=location-D1-3 score=INFINITY \\ ++ date in_range 2023-01-01T12:00 to 2023-12-31T12:00; ++ pcs -- constraint location resource%D1 rule \\ ++ id=location-D1-4-rule constraint-id=location-D1-4 score=INFINITY \\ ++ date in_range 2023-01-01T12:00 to 2023-12-31T12:00 ++ """ ++ ), ++ ) ++ + def testAdvancedConstraintRule(self): + self.fixture_resources() + stdout, stderr, retval = pcs( +diff --git a/pcs_test/tier1/legacy/test_rule.py b/pcs_test/tier1/legacy/test_rule.py +index eff3f878..b8f37e5d 100644 +--- a/pcs_test/tier1/legacy/test_rule.py ++++ b/pcs_test/tier1/legacy/test_rule.py +@@ -453,10 +453,18 @@ class ParserTest(TestCase): + "(gt (literal date) (literal 2014-06-26))", + str(self.parser.parse(["date", "gt", "2014-06-26"])), + ) ++ self.assertEqual( ++ "(gt (literal date) (literal 2014-06-26 12:00:00))", ++ str(self.parser.parse(["date", "gt", "2014-06-26 12:00:00"])), ++ ) + self.assertEqual( + "(lt (literal date) (literal 2014-06-26))", + str(self.parser.parse(["date", "lt", "2014-06-26"])), + ) ++ self.assertEqual( ++ "(lt (literal date) (literal 2014-06-26 12:00:00))", ++ str(self.parser.parse(["date", "lt", "2014-06-26 12:00:00"])), ++ ) + self.assertEqual( + "(in_range " + "(literal date) (literal 2014-06-26) (literal 2014-07-26)" +@@ -467,6 +475,22 @@ class ParserTest(TestCase): + ) + ), + ) ++ self.assertEqual( ++ "(in_range " ++ "(literal date) (literal 2014-06-26 12:00) (literal 2014-07-26 13:00)" ++ ")", ++ str( ++ self.parser.parse( ++ [ ++ "date", ++ "in_range", ++ "2014-06-26 12:00", ++ "to", ++ "2014-07-26 13:00", ++ ] ++ ) ++ ), ++ ) + self.assertEqual( + "(in_range " + "(literal date) " +diff --git a/pcs_test/tools/constraints_dto.py b/pcs_test/tools/constraints_dto.py +index f9c91510..c1ac7454 100644 +--- a/pcs_test/tools/constraints_dto.py ++++ b/pcs_test/tools/constraints_dto.py +@@ -160,6 +160,7 @@ def get_all_constraints( + "loc_constr_with_not_expired_rule-rule" + ), + options={ ++ "boolean-op": "and", + "role": "Unpromoted", + "score": "500", + }, +@@ -168,10 +169,26 @@ def get_all_constraints( + expressions=[ + CibRuleExpressionDto( + id="loc_constr_with_not_expired_rule-rule-expr", +- type=CibRuleExpressionType.DATE_EXPRESSION, ++ type=CibRuleExpressionType.EXPRESSION, + in_effect=rule_eval.get_rule_status( + "loc_constr_with_not_expired_rule-rule-expr" + ), ++ options={ ++ "operation": "eq", ++ "attribute": "#uname", ++ "value": "node1", ++ }, ++ date_spec=None, ++ duration=None, ++ expressions=[], ++ as_string="#uname eq node1", ++ ), ++ CibRuleExpressionDto( ++ id="loc_constr_with_not_expired_rule-rule-expr-1", ++ type=CibRuleExpressionType.DATE_EXPRESSION, ++ in_effect=rule_eval.get_rule_status( ++ "loc_constr_with_not_expired_rule-rule-expr-1" ++ ), + options={ + "operation": "gt", + "start": "2000-01-01", +@@ -180,9 +197,9 @@ def get_all_constraints( + duration=None, + expressions=[], + as_string="date gt 2000-01-01", +- ) ++ ), + ], +- as_string="date gt 2000-01-01", ++ as_string="#uname eq node1 and date gt 2000-01-01", + ), + CibRuleExpressionDto( + id="loc_constr_with_not_expired_rule-rule-1", +@@ -191,6 +208,7 @@ def get_all_constraints( + "loc_constr_with_not_expired_rule-rule-1" + ), + options={ ++ "boolean-op": "and", + "role": "Promoted", + "score-attribute": "test-attr", + }, +@@ -211,9 +229,25 @@ def get_all_constraints( + duration=None, + expressions=[], + as_string="date gt 2010-12-31", +- ) ++ ), ++ CibRuleExpressionDto( ++ id="loc_constr_with_not_expired_rule-rule-1-expr-1", ++ type=CibRuleExpressionType.EXPRESSION, ++ in_effect=rule_eval.get_rule_status( ++ "loc_constr_with_not_expired_rule-rule-1-expr-1" ++ ), ++ options={ ++ "operation": "eq", ++ "attribute": "#uname", ++ "value": "node1", ++ }, ++ date_spec=None, ++ duration=None, ++ expressions=[], ++ as_string="#uname eq node1", ++ ), + ], +- as_string="date gt 2010-12-31", ++ as_string="date gt 2010-12-31 and #uname eq node1", + ), + ], + lifetime=[], +-- +2.41.0 + diff --git a/bz2210855-01-fix-resource-move-regression.patch b/bz2210855-01-fix-resource-move-regression.patch deleted file mode 100644 index b7c5f48..0000000 --- a/bz2210855-01-fix-resource-move-regression.patch +++ /dev/null @@ -1,64 +0,0 @@ -From 835939b80f3e75616eb585bb1e17dec0a6083a88 Mon Sep 17 00:00:00 2001 -From: Tomas Jelinek -Date: Tue, 30 May 2023 13:07:47 +0200 -Subject: [PATCH] fix resource move regression - ---- - CHANGELOG.md | 3 +++ - pcs/lib/pacemaker/live.py | 5 ++++- - pcs_test/tools/custom_mock.py | 2 +- - 3 files changed, 8 insertions(+), 2 deletions(-) - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index c7661929..0c56758c 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -27,6 +27,8 @@ - Pacemaker-2.1.5-rc1 ([rhbz#2177996]) - - Make `pcs resource disable --simulate --brief` documentation clearer - ([rhbz#2109852]) -+- Fixed a regression causing crash in `pcs resource move` command (broken since -+ pcs-0.11.5) ([rhbz#2210855]) - - ### Changed - - Commands for displaying cluster configuration have been slightly updated: -@@ -50,6 +52,7 @@ - [rhbz#2175881]: https://bugzilla.redhat.com/show_bug.cgi?id=2175881 - [rhbz#2177996]: https://bugzilla.redhat.com/show_bug.cgi?id=2177996 - [rhbz#2179388]: https://bugzilla.redhat.com/show_bug.cgi?id=2179388 -+[rhbz#2210855]: https://bugzilla.redhat.com/show_bug.cgi?id=2210855 - - - ## [0.11.5] - 2023-03-01 -diff --git a/pcs/lib/pacemaker/live.py b/pcs/lib/pacemaker/live.py -index c2ba5d60..5a4bfec2 100644 ---- a/pcs/lib/pacemaker/live.py -+++ b/pcs/lib/pacemaker/live.py -@@ -384,7 +384,10 @@ def simulate_cib_xml(runner, cib_xml): - string cib_xml -- CIB XML to simulate - """ - try: -- with tools.get_tmp_file() as new_cib_file, tools.get_tmp_file() as transitions_file: -+ with ( -+ tools.get_tmp_file(None) as new_cib_file, -+ tools.get_tmp_file(None) as transitions_file, -+ ): - cmd = [ - __exec("crm_simulate"), - "--simulate", -diff --git a/pcs_test/tools/custom_mock.py b/pcs_test/tools/custom_mock.py -index 9380afce..90f12abd 100644 ---- a/pcs_test/tools/custom_mock.py -+++ b/pcs_test/tools/custom_mock.py -@@ -101,7 +101,7 @@ class TmpFileMock: - except StopIteration: - pass - -- def _mock_side_effect(self, data=None, binary=False): -+ def _mock_side_effect(self, data, binary=False): - def _seek_callback(offset): - if offset != 0: - raise AssertionError( --- -2.40.1 - diff --git a/bz2217850-01-fix-exporting-location-constraints-with-rules.patch b/bz2217850-01-fix-exporting-location-constraints-with-rules.patch new file mode 100644 index 0000000..d3cdb06 --- /dev/null +++ b/bz2217850-01-fix-exporting-location-constraints-with-rules.patch @@ -0,0 +1,302 @@ +From 24a8e84e3f81fc846a8d60dc636c9d42fc7a0cd8 Mon Sep 17 00:00:00 2001 +From: Miroslav Lisik +Date: Tue, 4 Jul 2023 21:43:38 +0200 +Subject: [PATCH 2/3] fix displaying duplicate records in property commands + +--- + pcs/cli/cluster_property/output.py | 65 +++++++++---------- + .../cli/cluster_property/test_command.py | 15 +++++ + .../tier0/cli/cluster_property/test_output.py | 31 ++++++--- + .../lib/commands/test_cluster_property.py | 28 ++++++++ + 4 files changed, 93 insertions(+), 46 deletions(-) + +diff --git a/pcs/cli/cluster_property/output.py b/pcs/cli/cluster_property/output.py +index c538c5c1..c9c46d1c 100644 +--- a/pcs/cli/cluster_property/output.py ++++ b/pcs/cli/cluster_property/output.py +@@ -31,21 +31,15 @@ class PropertyConfigurationFacade: + readonly_properties: StringCollection, + ) -> None: + self._properties = properties ++ self._first_nvpair_set = ( ++ self._properties[0].nvpairs if self._properties else [] ++ ) + self._properties_metadata = properties_metadata + self._readonly_properties = readonly_properties +- self._defaults_map = { +- metadata.name: metadata.default +- for metadata in self._properties_metadata +- if metadata.default is not None ++ self._defaults_map = self.get_defaults(include_advanced=True) ++ self._name_nvpair_dto_map = { ++ nvpair_dto.name: nvpair_dto for nvpair_dto in self._first_nvpair_set + } +- self._name_nvpair_dto_map = ( +- { +- nvpair_dto.name: nvpair_dto +- for nvpair_dto in self._properties[0].nvpairs +- } +- if self._properties +- else {} +- ) + + @classmethod + def from_properties_dtos( +@@ -105,17 +99,6 @@ class PropertyConfigurationFacade: + return value + return self._defaults_map.get(property_name, custom_default) + +- @staticmethod +- def _filter_names_advanced( +- metadata: ResourceAgentParameterDto, +- property_names: Optional[StringSequence] = None, +- include_advanced: bool = False, +- ) -> bool: +- return bool( +- (not property_names and (include_advanced or not metadata.advanced)) +- or (property_names and metadata.name in property_names) +- ) +- + def get_defaults( + self, + property_names: Optional[StringSequence] = None, +@@ -123,11 +106,10 @@ class PropertyConfigurationFacade: + ) -> dict[str, str]: + return { + metadata.name: metadata.default +- for metadata in self._properties_metadata +- if metadata.default is not None +- and self._filter_names_advanced( +- metadata, property_names, include_advanced ++ for metadata in self.get_properties_metadata( ++ property_names, include_advanced + ) ++ if metadata.default is not None + } + + def get_properties_metadata( +@@ -135,23 +117,34 @@ class PropertyConfigurationFacade: + property_names: Optional[StringSequence] = None, + include_advanced: bool = False, + ) -> Sequence[ResourceAgentParameterDto]: +- return [ +- metadata +- for metadata in self._properties_metadata +- if self._filter_names_advanced( +- metadata, property_names, include_advanced +- ) +- ] ++ if property_names: ++ filtered_metadata = [ ++ metadata ++ for metadata in self._properties_metadata ++ if metadata.name in property_names ++ ] ++ else: ++ filtered_metadata = [ ++ metadata ++ for metadata in self._properties_metadata ++ if include_advanced or not metadata.advanced ++ ] ++ deduplicated_metadata = { ++ metadata.name: metadata for metadata in filtered_metadata ++ } ++ return list(deduplicated_metadata.values()) + + def get_name_value_default_list(self) -> list[tuple[str, str, bool]]: + name_value_default_list = [ + (nvpair_dto.name, nvpair_dto.value, False) +- for nvpair_dto in self._name_nvpair_dto_map.values() ++ for nvpair_dto in self._first_nvpair_set + ] + name_value_default_list.extend( + [ + (metadata_dto.name, metadata_dto.default, True) +- for metadata_dto in self._properties_metadata ++ for metadata_dto in self.get_properties_metadata( ++ include_advanced=True ++ ) + if metadata_dto.name not in self._name_nvpair_dto_map + and metadata_dto.default is not None + ] +diff --git a/pcs_test/tier0/cli/cluster_property/test_command.py b/pcs_test/tier0/cli/cluster_property/test_command.py +index b54d0e58..f8cc2afa 100644 +--- a/pcs_test/tier0/cli/cluster_property/test_command.py ++++ b/pcs_test/tier0/cli/cluster_property/test_command.py +@@ -21,6 +21,21 @@ from pcs_test.tools.misc import dict_to_modifiers + + FIXTURE_PROPERTY_METADATA = ClusterPropertyMetadataDto( + properties_metadata=[ ++ ResourceAgentParameterDto( ++ name="property_name", ++ shortdesc="Duplicate property", ++ longdesc=None, ++ type="string", ++ default="duplicate_default", ++ enum_values=None, ++ required=False, ++ advanced=False, ++ deprecated=False, ++ deprecated_by=[], ++ deprecated_desc=None, ++ unique_group=None, ++ reloadable=False, ++ ), + ResourceAgentParameterDto( + name="property_name", + shortdesc=None, +diff --git a/pcs_test/tier0/cli/cluster_property/test_output.py b/pcs_test/tier0/cli/cluster_property/test_output.py +index 0ce8f6a8..59d33466 100644 +--- a/pcs_test/tier0/cli/cluster_property/test_output.py ++++ b/pcs_test/tier0/cli/cluster_property/test_output.py +@@ -21,6 +21,7 @@ FIXTURE_TWO_PROPERTY_SETS = [ + CibNvpairDto(id="", name="readonly2", value="ro_val2"), + CibNvpairDto(id="", name="property2", value="val2"), + CibNvpairDto(id="", name="property1", value="val1"), ++ CibNvpairDto(id="", name="property1", value="duplicate_val1"), + ], + ), + CibNvsetDto( +@@ -39,6 +40,7 @@ FIXTURE_READONLY_PROPERTIES_LIST = ["readonly1", "readonly2"] + FIXTURE_TEXT_OUTPUT_FIRST_SET = dedent( + """\ + Cluster Properties: id1 score=150 ++ property1=duplicate_val1 + property1=val1 + property2=val2 + readonly1=ro_val1 +@@ -75,6 +77,7 @@ def fixture_property_metadata( + + + FIXTURE_PROPERTY_METADATA_LIST = [ ++ fixture_property_metadata(name="property1", default="duplicate_default1"), + fixture_property_metadata(name="property1", default="default1"), + fixture_property_metadata(name="property2", default="default2"), + fixture_property_metadata( +@@ -136,7 +139,7 @@ class TestPropertyConfigurationFacadeGetPropertyValue(TestCase): + ) + + def test_property_value_from_first_set(self): +- self.assertEqual(self.facade.get_property_value("property1"), "val1") ++ self.assertEqual(self.facade.get_property_value("property2"), "val2") + + def test_property_value_from_second_set(self): + self.assertEqual(self.facade.get_property_value("property3"), None) +@@ -152,6 +155,11 @@ class TestPropertyConfigurationFacadeGetPropertyValue(TestCase): + "custom", + ) + ++ def test_property_with_multiple_values(self): ++ self.assertEqual( ++ self.facade.get_property_value("property1"), "duplicate_val1" ++ ) ++ + + class TestPropertyConfigurationFacadeGetPropertyValueOrDefault(TestCase): + def setUp(self): +@@ -163,7 +171,7 @@ class TestPropertyConfigurationFacadeGetPropertyValueOrDefault(TestCase): + + def test_property_value_from_first_set(self): + self.assertEqual( +- self.facade.get_property_value_or_default("property1"), "val1" ++ self.facade.get_property_value_or_default("property2"), "val2" + ) + + def test_property_value_not_in_set(self): +@@ -239,21 +247,22 @@ class TestPropertyConfigurationFacadeGetPropertiesMetadata(TestCase): + ) + + def test_metadata_without_advanced(self): +- metadata = FIXTURE_PROPERTY_METADATA_LIST[0:2] +- self.assertEqual(self.facade.get_properties_metadata(), metadata) ++ metadata = FIXTURE_PROPERTY_METADATA_LIST[1:3] ++ self.assertCountEqual(self.facade.get_properties_metadata(), metadata) + + def test_metadata_with_advanced(self): +- metadata = FIXTURE_PROPERTY_METADATA_LIST +- self.assertEqual( +- self.facade.get_properties_metadata(include_advanced=True), metadata ++ metadata = FIXTURE_PROPERTY_METADATA_LIST[1:] ++ self.assertCountEqual( ++ self.facade.get_properties_metadata(include_advanced=True), ++ metadata, + ) + + def test_metadata_specified(self): + metadata = ( +- FIXTURE_PROPERTY_METADATA_LIST[0:1] ++ FIXTURE_PROPERTY_METADATA_LIST[1:2] + + FIXTURE_PROPERTY_METADATA_LIST[-1:] + ) +- self.assertEqual( ++ self.assertCountEqual( + self.facade.get_properties_metadata( + property_names=["property4", "property1"] + ), +@@ -275,6 +284,7 @@ class TestPropertyConfigurationFacadeGetNameValueDefaultList(TestCase): + ("readonly2", "ro_val2", False), + ("property2", "val2", False), + ("property1", "val1", False), ++ ("property1", "duplicate_val1", False), + ("property3", "default3", True), + ("property4", "default4", True), + ] +@@ -503,7 +513,8 @@ class TestPropertiesToCmd(TestCase): + """\ + pcs property set --force -- \\ + property2=val2 \\ +- property1=val1 ++ property1=val1 \\ ++ property1=duplicate_val1 + """ + ) + self.assert_lines(facade, output) +diff --git a/pcs_test/tier0/lib/commands/test_cluster_property.py b/pcs_test/tier0/lib/commands/test_cluster_property.py +index c7cb7ae5..c02761a0 100644 +--- a/pcs_test/tier0/lib/commands/test_cluster_property.py ++++ b/pcs_test/tier0/lib/commands/test_cluster_property.py +@@ -911,6 +911,10 @@ class TestGetProperties(TestCase): + ) + self.env_assist.assert_reports([]) + ++ @mock.patch( ++ "pcs.lib.cib.rule.in_effect.has_rule_in_effect_status_tool", ++ lambda: True, ++ ) + def test_evaluate_expired_but_no_set_rule(self): + self.config.runner.cib.load( + crm_config=fixture_crm_config_properties([("set_id", {})]) +@@ -924,6 +928,30 @@ class TestGetProperties(TestCase): + ), + ) + ++ @mock.patch( ++ "pcs.lib.cib.rule.in_effect.has_rule_in_effect_status_tool", ++ lambda: False, ++ ) ++ def test_evaluate_expired_no_status_tool(self): ++ self.config.runner.cib.load( ++ crm_config=fixture_crm_config_properties([("set_id", {})]) ++ ) ++ self.assertEqual( ++ self.command(evaluate_expired=True), ++ ListCibNvsetDto( ++ nvsets=[ ++ CibNvsetDto(id="set_id", options={}, rule=None, nvpairs=[]) ++ ] ++ ), ++ ) ++ self.env_assist.assert_reports( ++ [ ++ fixture.warn( ++ reports.codes.RULE_IN_EFFECT_STATUS_DETECTION_NOT_SUPPORTED, ++ ) ++ ] ++ ) ++ + + class TestGetPropertiesMetadata(MetadataErrorMixin, TestCase): + _load_cib_when_metadata_error = False +-- +2.41.0 + diff --git a/bz2219407-01-use-a-filter-when-extracting-a-config-backup-tarball.patch b/bz2219407-01-use-a-filter-when-extracting-a-config-backup-tarball.patch new file mode 100644 index 0000000..f86e29a --- /dev/null +++ b/bz2219407-01-use-a-filter-when-extracting-a-config-backup-tarball.patch @@ -0,0 +1,55 @@ +From e47799cbdd588649872efd24d6bcfa78acb23ecb Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Tue, 11 Jul 2023 14:09:17 +0200 +Subject: [PATCH 3/3] use a filter when extracting a config backup tarball + +--- + pcs/config.py | 26 ++++++++++++++++++++++++-- + 1 file changed, 24 insertions(+), 2 deletions(-) + +diff --git a/pcs/config.py b/pcs/config.py +index 56c49aae..d750f52f 100644 +--- a/pcs/config.py ++++ b/pcs/config.py +@@ -488,14 +488,36 @@ def config_restore_local(infile_name, infile_obj): + if "rename" in extract_info and extract_info["rename"]: + if tmp_dir is None: + tmp_dir = tempfile.mkdtemp() +- tarball.extractall(tmp_dir, [tar_member_info]) ++ if hasattr(tarfile, "data_filter"): ++ # Safe way of extraction is available since Python 3.12, ++ # hasattr above checks if it's available. ++ # It's also backported to 3.11.4, 3.10.12, 3.9.17. ++ # It may be backported to older versions in downstream. ++ tarball.extractall( ++ tmp_dir, [tar_member_info], filter="data" ++ ) ++ else: ++ # Unsafe way of extraction ++ # Remove once we don't support Python 3.8 and older ++ tarball.extractall(tmp_dir, [tar_member_info]) + path_full = extract_info["path"] + shutil.move( + os.path.join(tmp_dir, tar_member_info.name), path_full + ) + else: + dir_path = os.path.dirname(extract_info["path"]) +- tarball.extractall(dir_path, [tar_member_info]) ++ if hasattr(tarfile, "data_filter"): ++ # Safe way of extraction is available since Python 3.12, ++ # hasattr above checks if it's available. ++ # It's also backported to 3.11.4, 3.10.12, 3.9.17. ++ # It may be backported to older versions in downstream. ++ tarball.extractall( ++ dir_path, [tar_member_info], filter="data" ++ ) ++ else: ++ # Unsafe way of extracting ++ # Remove once we don't support Python 3.8 and older ++ tarball.extractall(dir_path, [tar_member_info]) + path_full = os.path.join(dir_path, tar_member_info.name) + file_attrs = extract_info["attrs"] + os.chmod(path_full, file_attrs["mode"]) +-- +2.41.0 + diff --git a/pcs.spec b/pcs.spec index 110e93d..6903323 100644 --- a/pcs.spec +++ b/pcs.spec @@ -1,6 +1,6 @@ Name: pcs Version: 0.11.6 -Release: 1%{?dist} +Release: 2%{?dist} # https://docs.fedoraproject.org/en-US/packaging-guidelines/LicensingGuidelines/ # https://fedoraproject.org/wiki/Licensing:Main?rd=Licensing#Good_Licenses # GPL-2.0-only: pcs @@ -101,6 +101,9 @@ Source101: https://github.com/ClusterLabs/pcs-web-ui/releases/download/%{ui_comm # pcs patches: <= 200 # Patch0: bzNUMBER-01-name.patch Patch0: do-not-support-cluster-setup-with-udp-u-transport.patch +Patch1: bz2163953-01-constraint-fixes.patch +Patch2: bz2217850-01-fix-exporting-location-constraints-with-rules.patch +Patch3: bz2219407-01-use-a-filter-when-extracting-a-config-backup-tarball.patch # ui patches: >200 # Patch201: bzNUMBER-01-name.patch @@ -158,6 +161,8 @@ BuildRequires: resource-agents BuildRequires: sbd # for working with qdevice certificates (certutil) - used in configure.ac BuildRequires: nss-tools +# for generating MiniDebugInfo with find-debuginfo +BuildRequires: debugedit # python and libraries for pcs, setuptools for pcs entrypoint Requires: python3 >= 3.9 @@ -291,6 +296,9 @@ update_times_patch(){ %autopatch -p1 -M 200 # update_times_patch %%{PATCH0} update_times_patch %{PATCH0} +update_times_patch %{PATCH1} +update_times_patch %{PATCH2} +update_times_patch %{PATCH3} # generate .tarball-version if building from an untagged commit, not a released version # autogen uses git-version-gen which uses .tarball-version for generating version number @@ -379,10 +387,10 @@ cp %{pcs_bundled_dir}/src/dacite-*/README.md dacite_README.md # We are not building debug package for pcs but we need to add MiniDebuginfo # to the bundled shared libraries from rubygem extensions in order to satisfy # rpmdiff's binary stripping checker. -# Therefore we call find-debuginfo.sh script manually in order to strip +# Therefore we call find-debuginfo from debugedit manually in order to strip # binaries and add MiniDebugInfo with .gnu_debugdata section -/usr/lib/rpm/find-debuginfo.sh -j2 -m -i -S debugsourcefiles.list -# find-debuginfo.sh generated some files into /usr/lib/debug and +find-debuginfo -j2 -m -i -S debugsourcefiles.list +# find-debuginfo generated some files into /usr/lib/debug and # /usr/src/debug/ that we don't want in the package rm -rf $RPM_BUILD_ROOT%{_libdir}/debug rm -rf $RPM_BUILD_ROOT/usr/lib/debug @@ -527,6 +535,14 @@ run_all_tests %license pyagentx_LICENSE.txt %changelog +* Mon Jul 10 2023 Michal Pospisil - 0.11.6-2 +- Added BuildRequires: debugedit - for generating MiniDebugInfo - triggered by removing find-debuginfo.sh from rpm +- Make use of filters when extracting tarballs to enhance security if provided by Python (pcs config restore command) +- Exporting constraints with rules in form of pcs commands now escapes # and fixes spaces in dates to make the commands valid +- Constraints containing options unsupported by pcs are not exported and a warning is printed instead +- Using spaces in dates in location constraint rules is deprecated +- Resolves: rhbz#2163953 rhbz#2216434 rhbz#2217850 rhbz#2219407 + * Tue Jun 20 2023 Michal Pospisil - 0.11.6-1 - Rebased to the latest upstream sources (see CHANGELOG.md) - Updated bundled rubygems: puma, tilt