From ec4f8fc199891ad13235729272c0f115918cade9 Mon Sep 17 00:00:00 2001 From: Tomas Jelinek Date: Thu, 21 May 2020 16:51:25 +0200 Subject: [PATCH 1/3] squash bz1817547 Resource and operation defaults that apply to specific resource/operation types add rule parser for rsc and op expressions improvements to rule parser make rule parts independent of the parser export parsed rules into cib add a command for adding new rsc and op defaults display rsc and op defaults with multiple nvsets fix parsing and processing of rsc_expression in rules improve syntax for creating a new nvset make the rule parser produce dataclasses fix for pyparsing-2.4.0 add commands for removing rsc and op defaults sets add commands for updating rsc and op defaults sets update chagelog, capabilities add tier1 tests for rules various minor fixes fix routing, create 'defaults update' command better error messages for unallowed rule expressions --- .gitlab-ci.yml | 3 + README.md | 1 + mypy.ini | 9 + pcs.spec.in | 3 + pcs/cli/common/lib_wrapper.py | 10 +- pcs/cli/nvset.py | 53 ++ pcs/cli/reports/messages.py | 39 + pcs/cli/routing/resource.py | 77 +- pcs/cli/rule.py | 89 +++ pcs/common/interface/dto.py | 9 +- pcs/common/pacemaker/nvset.py | 26 + pcs/common/pacemaker/rule.py | 28 + pcs/common/reports/codes.py | 3 + pcs/common/reports/const.py | 6 + pcs/common/reports/messages.py | 73 ++ pcs/common/reports/types.py | 1 + pcs/common/str_tools.py | 32 + pcs/common/types.py | 13 + pcs/config.py | 20 +- pcs/lib/cib/nvpair_multi.py | 323 +++++++++ pcs/lib/cib/rule/__init__.py | 8 + pcs/lib/cib/rule/cib_to_dto.py | 185 +++++ pcs/lib/cib/rule/expression_part.py | 49 ++ pcs/lib/cib/rule/parsed_to_cib.py | 103 +++ pcs/lib/cib/rule/parser.py | 232 ++++++ pcs/lib/cib/rule/validator.py | 62 ++ pcs/lib/cib/tools.py | 8 +- pcs/lib/commands/cib_options.py | 322 ++++++++- pcs/lib/validate.py | 15 + pcs/lib/xml_tools.py | 9 +- pcs/pcs.8 | 86 ++- pcs/resource.py | 258 ++++++- pcs/usage.py | 94 ++- pcs_test/resources/cib-empty-3.1.xml | 2 +- pcs_test/resources/cib-empty-3.2.xml | 2 +- pcs_test/resources/cib-empty-3.3.xml | 10 + pcs_test/resources/cib-empty-3.4.xml | 10 + pcs_test/resources/cib-empty.xml | 2 +- pcs_test/tier0/cli/reports/test_messages.py | 29 + pcs_test/tier0/cli/resource/test_defaults.py | 324 +++++++++ pcs_test/tier0/cli/test_nvset.py | 92 +++ pcs_test/tier0/cli/test_rule.py | 477 +++++++++++++ .../tier0/common/reports/test_messages.py | 55 +- pcs_test/tier0/common/test_str_tools.py | 33 + .../cib_options => cib/rule}/__init__.py | 0 .../tier0/lib/cib/rule/test_cib_to_dto.py | 593 ++++++++++++++++ .../tier0/lib/cib/rule/test_parsed_to_cib.py | 214 ++++++ pcs_test/tier0/lib/cib/rule/test_parser.py | 270 +++++++ pcs_test/tier0/lib/cib/rule/test_validator.py | 68 ++ pcs_test/tier0/lib/cib/test_nvpair_multi.py | 513 ++++++++++++++ pcs_test/tier0/lib/cib/test_tools.py | 13 +- .../cib_options/test_operations_defaults.py | 120 ---- .../cib_options/test_resources_defaults.py | 120 ---- .../tier0/lib/commands/test_cib_options.py | 669 ++++++++++++++++++ pcs_test/tier0/lib/test_validate.py | 27 + pcs_test/tier1/legacy/test_resource.py | 8 +- pcs_test/tier1/legacy/test_stonith.py | 8 +- pcs_test/tier1/test_cib_options.py | 571 +++++++++++++++ pcs_test/tier1/test_tag.py | 4 +- pcs_test/tools/fixture.py | 4 +- pcs_test/tools/misc.py | 61 +- pcsd/capabilities.xml | 30 + test/centos8/Dockerfile | 1 + test/fedora30/Dockerfile | 1 + test/fedora31/Dockerfile | 1 + test/fedora32/Dockerfile | 1 + 66 files changed, 6216 insertions(+), 366 deletions(-) create mode 100644 pcs/cli/nvset.py create mode 100644 pcs/cli/rule.py create mode 100644 pcs/common/pacemaker/nvset.py create mode 100644 pcs/common/pacemaker/rule.py create mode 100644 pcs/lib/cib/nvpair_multi.py create mode 100644 pcs/lib/cib/rule/__init__.py create mode 100644 pcs/lib/cib/rule/cib_to_dto.py create mode 100644 pcs/lib/cib/rule/expression_part.py create mode 100644 pcs/lib/cib/rule/parsed_to_cib.py create mode 100644 pcs/lib/cib/rule/parser.py create mode 100644 pcs/lib/cib/rule/validator.py create mode 100644 pcs_test/resources/cib-empty-3.3.xml create mode 100644 pcs_test/resources/cib-empty-3.4.xml create mode 100644 pcs_test/tier0/cli/resource/test_defaults.py create mode 100644 pcs_test/tier0/cli/test_nvset.py create mode 100644 pcs_test/tier0/cli/test_rule.py rename pcs_test/tier0/lib/{commands/cib_options => cib/rule}/__init__.py (100%) create mode 100644 pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py create mode 100644 pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py create mode 100644 pcs_test/tier0/lib/cib/rule/test_parser.py create mode 100644 pcs_test/tier0/lib/cib/rule/test_validator.py create mode 100644 pcs_test/tier0/lib/cib/test_nvpair_multi.py delete mode 100644 pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py delete mode 100644 pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py create mode 100644 pcs_test/tier0/lib/commands/test_cib_options.py create mode 100644 pcs_test/tier1/test_cib_options.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 83eba12d..24444b72 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -51,6 +51,7 @@ pylint: python3-pip python3-pycurl python3-pyOpenSSL + python3-pyparsing findutils make time @@ -69,6 +70,7 @@ mypy: python3-pip python3-pycurl python3-pyOpenSSL + python3-pyparsing git make tar @@ -112,6 +114,7 @@ python_tier0_tests: python3-pip python3-pycurl python3-pyOpenSSL + python3-pyparsing which " - make install_pip diff --git a/README.md b/README.md index f888da68..efb4d0d5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ These are the runtime dependencies of pcs and pcsd: * python3-pycurl * python3-setuptools * python3-pyOpenSSL (python3-openssl) +* python3-pyparsing * python3-tornado 6.x * python dataclasses (`pip install dataclasses`; required only for python 3.6, already included in 3.7+) diff --git a/mypy.ini b/mypy.ini index ad3d1f18..ac6789a9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,12 +8,18 @@ disallow_untyped_defs = True [mypy-pcs.lib.cib.resource.relations] disallow_untyped_defs = True +[mypy-pcs.lib.cib.rule] +disallow_untyped_defs = True + [mypy-pcs.lib.cib.tag] disallow_untyped_defs = True [mypy-pcs.lib.commands.tag] disallow_untyped_defs = True +[mypy-pcs.lib.commands.cib_options] +disallow_untyped_defs = True + [mypy-pcs.lib.dr.*] disallow_untyped_defs = True disallow_untyped_calls = True @@ -84,3 +90,6 @@ ignore_missing_imports = True [mypy-distro] ignore_missing_imports = True + +[mypy-pyparsing] +ignore_missing_imports = True diff --git a/pcs.spec.in b/pcs.spec.in index c52c2fe4..e292a708 100644 --- a/pcs.spec.in +++ b/pcs.spec.in @@ -122,6 +122,8 @@ BuildRequires: platform-python-setuptools %endif BuildRequires: python3-devel +# for tier0 tests +BuildRequires: python3-pyparsing # gcc for compiling custom rubygems BuildRequires: gcc @@ -155,6 +157,7 @@ Requires: platform-python-setuptools Requires: python3-lxml Requires: python3-pycurl +Requires: python3-pyparsing # clufter and its dependencies Requires: python3-clufter => 0.70.0 %if "%{python3_version}" != "3.6" && "%{python3_version}" != "3.7" diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py index 9fd05ac0..192a3dac 100644 --- a/pcs/cli/common/lib_wrapper.py +++ b/pcs/cli/common/lib_wrapper.py @@ -388,8 +388,14 @@ def load_module(env, middleware_factory, name): env, middleware.build(middleware_factory.cib,), { - "set_operations_defaults": cib_options.set_operations_defaults, - "set_resources_defaults": cib_options.set_resources_defaults, + "operation_defaults_config": cib_options.operation_defaults_config, + "operation_defaults_create": cib_options.operation_defaults_create, + "operation_defaults_remove": cib_options.operation_defaults_remove, + "operation_defaults_update": cib_options.operation_defaults_update, + "resource_defaults_config": cib_options.resource_defaults_config, + "resource_defaults_create": cib_options.resource_defaults_create, + "resource_defaults_remove": cib_options.resource_defaults_remove, + "resource_defaults_update": cib_options.resource_defaults_update, }, ) diff --git a/pcs/cli/nvset.py b/pcs/cli/nvset.py new file mode 100644 index 00000000..69442df3 --- /dev/null +++ b/pcs/cli/nvset.py @@ -0,0 +1,53 @@ +from typing import ( + cast, + Iterable, + List, + Optional, +) + +from pcs.cli.rule import rule_expression_dto_to_lines +from pcs.common.pacemaker.nvset import CibNvsetDto +from pcs.common.str_tools import ( + format_name_value_list, + indent, +) +from pcs.common.types import CibNvsetType + + +def nvset_dto_list_to_lines( + nvset_dto_list: Iterable[CibNvsetDto], + with_ids: bool = False, + text_if_empty: Optional[str] = None, +) -> List[str]: + if not nvset_dto_list: + return [text_if_empty] if text_if_empty else [] + return [ + line + for nvset_dto in nvset_dto_list + for line in nvset_dto_to_lines(nvset_dto, with_ids=with_ids) + ] + + +def nvset_dto_to_lines(nvset: CibNvsetDto, with_ids: bool = False) -> List[str]: + nvset_label = _nvset_type_to_label.get(nvset.type, "Options Set") + heading_parts = [f"{nvset_label}: {nvset.id}"] + if nvset.options: + heading_parts.append( + " ".join(format_name_value_list(sorted(nvset.options.items()))) + ) + + lines = format_name_value_list( + sorted([(nvpair.name, nvpair.value) for nvpair in nvset.nvpairs]) + ) + if nvset.rule: + lines.extend( + rule_expression_dto_to_lines(nvset.rule, with_ids=with_ids) + ) + + return [" ".join(heading_parts)] + indent(lines) + + +_nvset_type_to_label = { + cast(str, CibNvsetType.INSTANCE): "Attributes", + cast(str, CibNvsetType.META): "Meta Attrs", +} diff --git a/pcs/cli/reports/messages.py b/pcs/cli/reports/messages.py index 36f00a9e..7ccc8ab0 100644 --- a/pcs/cli/reports/messages.py +++ b/pcs/cli/reports/messages.py @@ -402,6 +402,45 @@ class TagCannotRemoveReferencesWithoutRemovingTag(CliReportMessageCustom): ) +class RuleExpressionParseError(CliReportMessageCustom): + _obj: messages.RuleExpressionParseError + + @property + def message(self) -> str: + # Messages coming from the parser are not very useful and readable, + # they mostly contain one line grammar expression covering the whole + # rule. No user would be able to parse that. Therefore we omit the + # messages. + marker = "-" * (self._obj.column_number - 1) + "^" + return ( + f"'{self._obj.rule_string}' is not a valid rule expression, parse " + f"error near or after line {self._obj.line_number} column " + f"{self._obj.column_number}\n" + f" {self._obj.rule_line}\n" + f" {marker}" + ) + + +class CibNvsetAmbiguousProvideNvsetId(CliReportMessageCustom): + _obj: messages.CibNvsetAmbiguousProvideNvsetId + + @property + def message(self) -> str: + command_map = { + const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE: ( + "pcs resource defaults set update" + ), + const.PCS_COMMAND_OPERATION_DEFAULTS_UPDATE: ( + "pcs resource op defaults set update" + ), + } + command = command_map.get(self._obj.pcs_command, "") + return ( + f"Several options sets exist, please use the '{command}' command " + "and specify an option set ID" + ) + + def _create_report_msg_map() -> Dict[str, type]: result: Dict[str, type] = {} for report_msg_cls in get_all_subclasses(CliReportMessageCustom): diff --git a/pcs/cli/routing/resource.py b/pcs/cli/routing/resource.py index 28bb3d5e..0706f43b 100644 --- a/pcs/cli/routing/resource.py +++ b/pcs/cli/routing/resource.py @@ -1,15 +1,88 @@ from functools import partial +from typing import ( + Any, + List, +) from pcs import ( resource, usage, ) from pcs.cli.common.errors import raise_command_replaced +from pcs.cli.common.parse_args import InputModifiers from pcs.cli.common.routing import create_router from pcs.cli.resource.relations import show_resource_relations_cmd +def resource_defaults_cmd( + lib: Any, argv: List[str], modifiers: InputModifiers +) -> None: + """ + Options: + * -f - CIB file + * --force - allow unknown options + """ + if argv and "=" in argv[0]: + # DEPRECATED legacy command + return resource.resource_defaults_legacy_cmd( + lib, argv, modifiers, deprecated_syntax_used=True + ) + + router = create_router( + { + "config": resource.resource_defaults_config_cmd, + "set": create_router( + { + "create": resource.resource_defaults_set_create_cmd, + "delete": resource.resource_defaults_set_remove_cmd, + "remove": resource.resource_defaults_set_remove_cmd, + "update": resource.resource_defaults_set_update_cmd, + }, + ["resource", "defaults", "set"], + ), + "update": resource.resource_defaults_legacy_cmd, + }, + ["resource", "defaults"], + default_cmd="config", + ) + return router(lib, argv, modifiers) + + +def resource_op_defaults_cmd( + lib: Any, argv: List[str], modifiers: InputModifiers +) -> None: + """ + Options: + * -f - CIB file + * --force - allow unknown options + """ + if argv and "=" in argv[0]: + # DEPRECATED legacy command + return resource.resource_op_defaults_legacy_cmd( + lib, argv, modifiers, deprecated_syntax_used=True + ) + + router = create_router( + { + "config": resource.resource_op_defaults_config_cmd, + "set": create_router( + { + "create": resource.resource_op_defaults_set_create_cmd, + "delete": resource.resource_op_defaults_set_remove_cmd, + "remove": resource.resource_op_defaults_set_remove_cmd, + "update": resource.resource_op_defaults_set_update_cmd, + }, + ["resource", "op", "defaults", "set"], + ), + "update": resource.resource_op_defaults_legacy_cmd, + }, + ["resource", "op", "defaults"], + default_cmd="config", + ) + return router(lib, argv, modifiers) + + resource_cmd = create_router( { "help": lambda lib, argv, modifiers: usage.resource(argv), @@ -68,14 +141,14 @@ resource_cmd = create_router( "failcount": resource.resource_failcount, "op": create_router( { - "defaults": resource.resource_op_defaults_cmd, + "defaults": resource_op_defaults_cmd, "add": resource.resource_op_add_cmd, "remove": resource.resource_op_delete_cmd, "delete": resource.resource_op_delete_cmd, }, ["resource", "op"], ), - "defaults": resource.resource_defaults_cmd, + "defaults": resource_defaults_cmd, "cleanup": resource.resource_cleanup, "refresh": resource.resource_refresh, "relocate": create_router( diff --git a/pcs/cli/rule.py b/pcs/cli/rule.py new file mode 100644 index 00000000..c1149fff --- /dev/null +++ b/pcs/cli/rule.py @@ -0,0 +1,89 @@ +from typing import List + +from pcs.common.pacemaker.rule import CibRuleExpressionDto +from pcs.common.str_tools import ( + format_name_value_list, + indent, +) +from pcs.common.types import CibRuleExpressionType + + +def rule_expression_dto_to_lines( + rule_expr: CibRuleExpressionDto, with_ids: bool = False +) -> List[str]: + if rule_expr.type == CibRuleExpressionType.RULE: + return _rule_dto_to_lines(rule_expr, with_ids) + if rule_expr.type == CibRuleExpressionType.DATE_EXPRESSION: + return _date_dto_to_lines(rule_expr, with_ids) + return _simple_expr_to_lines(rule_expr, with_ids) + + +def _rule_dto_to_lines( + rule_expr: CibRuleExpressionDto, with_ids: bool = False +) -> List[str]: + heading_parts = [ + "Rule{0}:".format(" (expired)" if rule_expr.is_expired else "") + ] + heading_parts.extend( + format_name_value_list(sorted(rule_expr.options.items())) + ) + if with_ids: + heading_parts.append(f"(id:{rule_expr.id})") + + lines = [] + for child in rule_expr.expressions: + lines.extend(rule_expression_dto_to_lines(child, with_ids)) + + return [" ".join(heading_parts)] + indent(lines) + + +def _date_dto_to_lines( + rule_expr: CibRuleExpressionDto, with_ids: bool = False +) -> List[str]: + # pylint: disable=too-many-branches + operation = rule_expr.options.get("operation", None) + + if operation == "date_spec": + heading_parts = ["Expression:"] + if with_ids: + heading_parts.append(f"(id:{rule_expr.id})") + line_parts = ["Date Spec:"] + if rule_expr.date_spec: + line_parts.extend( + format_name_value_list( + sorted(rule_expr.date_spec.options.items()) + ) + ) + if with_ids: + line_parts.append(f"(id:{rule_expr.date_spec.id})") + return [" ".join(heading_parts)] + indent([" ".join(line_parts)]) + + if operation == "in_range" and rule_expr.duration: + heading_parts = ["Expression:", "date", "in_range"] + if "start" in rule_expr.options: + heading_parts.append(rule_expr.options["start"]) + heading_parts.extend(["to", "duration"]) + if with_ids: + heading_parts.append(f"(id:{rule_expr.id})") + lines = [" ".join(heading_parts)] + + line_parts = ["Duration:"] + line_parts.extend( + format_name_value_list(sorted(rule_expr.duration.options.items())) + ) + if with_ids: + line_parts.append(f"(id:{rule_expr.duration.id})") + lines.extend(indent([" ".join(line_parts)])) + + return lines + + return _simple_expr_to_lines(rule_expr, with_ids=with_ids) + + +def _simple_expr_to_lines( + rule_expr: CibRuleExpressionDto, with_ids: bool = False +) -> List[str]: + parts = ["Expression:", rule_expr.as_string] + if with_ids: + parts.append(f"(id:{rule_expr.id})") + return [" ".join(parts)] diff --git a/pcs/common/interface/dto.py b/pcs/common/interface/dto.py index fb40fc5e..768156d6 100644 --- a/pcs/common/interface/dto.py +++ b/pcs/common/interface/dto.py @@ -42,7 +42,14 @@ def from_dict(cls: Type[DtoType], data: DtoPayload) -> DtoType: data=data, # NOTE: all enum types has to be listed here in key cast # see: https://github.com/konradhalas/dacite#casting - config=dacite.Config(cast=[types.DrRole, types.ResourceRelationType,],), + config=dacite.Config( + cast=[ + types.CibNvsetType, + types.CibRuleExpressionType, + types.DrRole, + types.ResourceRelationType, + ] + ), ) diff --git a/pcs/common/pacemaker/nvset.py b/pcs/common/pacemaker/nvset.py new file mode 100644 index 00000000..6d72c787 --- /dev/null +++ b/pcs/common/pacemaker/nvset.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import ( + Mapping, + Optional, + Sequence, +) + +from pcs.common.interface.dto import DataTransferObject +from pcs.common.pacemaker.rule import CibRuleExpressionDto +from pcs.common.types import CibNvsetType + + +@dataclass(frozen=True) +class CibNvpairDto(DataTransferObject): + id: str # pylint: disable=invalid-name + name: str + value: str + + +@dataclass(frozen=True) +class CibNvsetDto(DataTransferObject): + id: str # pylint: disable=invalid-name + type: CibNvsetType + options: Mapping[str, str] + rule: Optional[CibRuleExpressionDto] + nvpairs: Sequence[CibNvpairDto] diff --git a/pcs/common/pacemaker/rule.py b/pcs/common/pacemaker/rule.py new file mode 100644 index 00000000..306e65e6 --- /dev/null +++ b/pcs/common/pacemaker/rule.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import ( + Mapping, + Optional, + Sequence, +) + +from pcs.common.interface.dto import DataTransferObject +from pcs.common.types import CibRuleExpressionType + + +@dataclass(frozen=True) +class CibRuleDateCommonDto(DataTransferObject): + id: str # pylint: disable=invalid-name + options: Mapping[str, str] + + +@dataclass(frozen=True) +class CibRuleExpressionDto(DataTransferObject): + # pylint: disable=too-many-instance-attributes + id: str # pylint: disable=invalid-name + type: CibRuleExpressionType + is_expired: bool # only valid for type==rule + options: Mapping[str, str] + date_spec: Optional[CibRuleDateCommonDto] + duration: Optional[CibRuleDateCommonDto] + expressions: Sequence["CibRuleExpressionDto"] + as_string: str diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py index 26eb8b51..8bcabfab 100644 --- a/pcs/common/reports/codes.py +++ b/pcs/common/reports/codes.py @@ -123,6 +123,7 @@ CIB_LOAD_ERROR = M("CIB_LOAD_ERROR") CIB_LOAD_ERROR_GET_NODES_FOR_VALIDATION = M( "CIB_LOAD_ERROR_GET_NODES_FOR_VALIDATION" ) +CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID = M("CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID") CIB_LOAD_ERROR_SCOPE_MISSING = M("CIB_LOAD_ERROR_SCOPE_MISSING") CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET = M( "CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET" @@ -405,6 +406,8 @@ RESOURCE_UNMOVE_UNBAN_PCMK_SUCCESS = M("RESOURCE_UNMOVE_UNBAN_PCMK_SUCCESS") RESOURCE_UNMOVE_UNBAN_PCMK_EXPIRED_NOT_SUPPORTED = M( "RESOURCE_UNMOVE_UNBAN_PCMK_EXPIRED_NOT_SUPPORTED" ) +RULE_EXPRESSION_PARSE_ERROR = M("RULE_EXPRESSION_PARSE_ERROR") +RULE_EXPRESSION_NOT_ALLOWED = M("RULE_EXPRESSION_NOT_ALLOWED") RUN_EXTERNAL_PROCESS_ERROR = M("RUN_EXTERNAL_PROCESS_ERROR") RUN_EXTERNAL_PROCESS_FINISHED = M("RUN_EXTERNAL_PROCESS_FINISHED") RUN_EXTERNAL_PROCESS_STARTED = M("RUN_EXTERNAL_PROCESS_STARTED") diff --git a/pcs/common/reports/const.py b/pcs/common/reports/const.py index aeb593ee..fa2122d0 100644 --- a/pcs/common/reports/const.py +++ b/pcs/common/reports/const.py @@ -1,9 +1,15 @@ from .types import ( DefaultAddressSource, + PcsCommand, ReasonType, ServiceAction, ) +PCS_COMMAND_OPERATION_DEFAULTS_UPDATE = PcsCommand( + "resource op defaults update" +) +PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE = PcsCommand("resource defaults update") + SERVICE_ACTION_START = ServiceAction("START") SERVICE_ACTION_STOP = ServiceAction("STOP") SERVICE_ACTION_ENABLE = ServiceAction("ENABLE") diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py index 540e8c69..f04d8632 100644 --- a/pcs/common/reports/messages.py +++ b/pcs/common/reports/messages.py @@ -27,6 +27,7 @@ from pcs.common.str_tools import ( indent, is_iterable_not_str, ) +from pcs.common.types import CibRuleExpressionType from . import ( codes, @@ -120,6 +121,7 @@ _type_articles = { "ACL user": "an", "ACL role": "an", "ACL permission": "an", + "options set": "an", } @@ -6399,3 +6401,74 @@ class TagIdsNotInTheTag(ReportItemMessage): ids=format_plural(self.id_list, "id"), id_list=format_list(self.id_list), ) + + +@dataclass(frozen=True) +class RuleExpressionParseError(ReportItemMessage): + """ + Unable to parse pacemaker cib rule expression string + + rule_string -- the whole rule expression string + reason -- error message from rule parser + rule_line -- part of rule_string - the line where the error occurred + line_number -- the line where parsing failed + column_number -- the column where parsing failed + position -- the start index where parsing failed + """ + + rule_string: str + reason: str + rule_line: str + line_number: int + column_number: int + position: int + _code = codes.RULE_EXPRESSION_PARSE_ERROR + + @property + def message(self) -> str: + # Messages coming from the parser are not very useful and readable, + # they mostly contain one line grammar expression covering the whole + # rule. No user would be able to parse that. Therefore we omit the + # messages. + return ( + f"'{self.rule_string}' is not a valid rule expression, parse error " + f"near or after line {self.line_number} column {self.column_number}" + ) + + +@dataclass(frozen=True) +class RuleExpressionNotAllowed(ReportItemMessage): + """ + Used rule expression is not allowed in current context + + expression_type -- disallowed expression type + """ + + expression_type: CibRuleExpressionType + _code = codes.RULE_EXPRESSION_NOT_ALLOWED + + @property + def message(self) -> str: + type_map = { + CibRuleExpressionType.OP_EXPRESSION: "op", + CibRuleExpressionType.RSC_EXPRESSION: "resource", + } + return ( + f"Keyword '{type_map[self.expression_type]}' cannot be used " + "in a rule in this command" + ) + + +@dataclass(frozen=True) +class CibNvsetAmbiguousProvideNvsetId(ReportItemMessage): + """ + An old command supporting only one nvset have been used when several nvsets + exist. We require an nvset ID the command should work with to be specified. + """ + + pcs_command: types.PcsCommand + _code = codes.CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID + + @property + def message(self) -> str: + return "Several options sets exist, please specify an option set ID" diff --git a/pcs/common/reports/types.py b/pcs/common/reports/types.py index 5973279e..541046ea 100644 --- a/pcs/common/reports/types.py +++ b/pcs/common/reports/types.py @@ -3,6 +3,7 @@ from typing import NewType DefaultAddressSource = NewType("DefaultAddressSource", str) ForceCode = NewType("ForceCode", str) MessageCode = NewType("MessageCode", str) +PcsCommand = NewType("PcsCommand", str) ReasonType = NewType("ReasonType", str) ServiceAction = NewType("ServiceAction", str) SeverityLevel = NewType("SeverityLevel", str) diff --git a/pcs/common/str_tools.py b/pcs/common/str_tools.py index deb38799..80864b50 100644 --- a/pcs/common/str_tools.py +++ b/pcs/common/str_tools.py @@ -3,6 +3,8 @@ from typing import ( Any, List, Mapping, + Sequence, + Tuple, TypeVar, ) @@ -49,6 +51,36 @@ def format_list_custom_last_separator( ) +# For now, Tuple[str, str] is sufficient. Feel free to change it if needed, +# e.g. when values can be integers. +def format_name_value_list(item_list: Sequence[Tuple[str, str]]) -> List[str]: + """ + Turn 2-tuples to 'name=value' strings with standard quoting + """ + output = [] + for name, value in item_list: + name = quote(name, "= ") + value = quote(value, "= ") + output.append(f"{name}={value}") + return output + + +def quote(string: str, chars_to_quote: str) -> str: + """ + Quote a string if it contains specified characters + + string -- the string to be processed + chars_to_quote -- the characters causing quoting + """ + if not frozenset(chars_to_quote) & frozenset(string): + return string + if '"' not in string: + return f'"{string}"' + if "'" not in string: + return f"'{string}'" + return '"{string}"'.format(string=string.replace('"', '\\"')) + + def join_multilines(strings): return "\n".join([a.strip() for a in strings if a.strip()]) diff --git a/pcs/common/types.py b/pcs/common/types.py index dace6f6d..0b656cc0 100644 --- a/pcs/common/types.py +++ b/pcs/common/types.py @@ -3,6 +3,19 @@ from enum import auto from pcs.common.tools import AutoNameEnum +class CibNvsetType(AutoNameEnum): + INSTANCE = auto() + META = auto() + + +class CibRuleExpressionType(AutoNameEnum): + RULE = auto() + EXPRESSION = auto() + DATE_EXPRESSION = auto() + OP_EXPRESSION = auto() + RSC_EXPRESSION = auto() + + class ResourceRelationType(AutoNameEnum): ORDER = auto() ORDER_SET = auto() diff --git a/pcs/config.py b/pcs/config.py index 058ec55a..67aa6e0e 100644 --- a/pcs/config.py +++ b/pcs/config.py @@ -48,6 +48,7 @@ from pcs import ( from pcs.cli.common import middleware from pcs.cli.common.errors import CmdLineInputError from pcs.cli.constraint import command as constraint_command +from pcs.cli.nvset import nvset_dto_list_to_lines from pcs.cli.reports import process_library_reports from pcs.common.reports import constraints as constraints_reports from pcs.common.str_tools import indent @@ -96,7 +97,8 @@ def _config_show_cib_lines(lib): Commandline options: * -f - CIB file """ - # update of pcs_options will change output of constraint show + # update of pcs_options will change output of constraint show and + # displaying resources and operations defaults utils.pcs_options["--full"] = 1 # get latest modifiers object after updating pcs_options modifiers = utils.get_input_modifiers() @@ -172,11 +174,23 @@ def _config_show_cib_lines(lib): all_lines.append("") all_lines.append("Resources Defaults:") all_lines.extend( - indent(resource.show_defaults(cib_dom, "rsc_defaults"), indent_step=1) + indent( + nvset_dto_list_to_lines( + lib.cib_options.resource_defaults_config(), + with_ids=modifiers.get("--full"), + text_if_empty="No defaults set", + ) + ) ) all_lines.append("Operations Defaults:") all_lines.extend( - indent(resource.show_defaults(cib_dom, "op_defaults"), indent_step=1) + indent( + nvset_dto_list_to_lines( + lib.cib_options.operation_defaults_config(), + with_ids=modifiers.get("--full"), + text_if_empty="No defaults set", + ) + ) ) all_lines.append("") diff --git a/pcs/lib/cib/nvpair_multi.py b/pcs/lib/cib/nvpair_multi.py new file mode 100644 index 00000000..7bdc2f55 --- /dev/null +++ b/pcs/lib/cib/nvpair_multi.py @@ -0,0 +1,323 @@ +from typing import ( + cast, + Iterable, + List, + Mapping, + NewType, + Optional, + Tuple, +) +from xml.etree.ElementTree import Element + +from lxml import etree +from lxml.etree import _Element + +from pcs.common import reports +from pcs.common.pacemaker.nvset import ( + CibNvpairDto, + CibNvsetDto, +) +from pcs.common.reports import ReportItemList +from pcs.common.types import CibNvsetType +from pcs.lib import validate +from pcs.lib.cib.rule import ( + RuleParseError, + RuleRoot, + RuleValidator, + parse_rule, + rule_element_to_dto, + rule_to_cib, +) +from pcs.lib.cib.tools import ( + ElementSearcher, + IdProvider, + create_subelement_id, +) +from pcs.lib.xml_tools import ( + export_attributes, + remove_one_element, +) + + +NvsetTag = NewType("NvsetTag", str) +NVSET_INSTANCE = NvsetTag("instance_attributes") +NVSET_META = NvsetTag("meta_attributes") + +_tag_to_type = { + str(NVSET_META): CibNvsetType.META, + str(NVSET_INSTANCE): CibNvsetType.INSTANCE, +} + + +def nvpair_element_to_dto(nvpair_el: Element) -> CibNvpairDto: + """ + Export an nvpair xml element to its DTO + """ + return CibNvpairDto( + nvpair_el.get("id", ""), + nvpair_el.get("name", ""), + nvpair_el.get("value", ""), + ) + + +def nvset_element_to_dto(nvset_el: Element) -> CibNvsetDto: + """ + Export an nvset xml element to its DTO + """ + rule_el = nvset_el.find("./rule") + return CibNvsetDto( + nvset_el.get("id", ""), + _tag_to_type[nvset_el.tag], + export_attributes(nvset_el, with_id=False), + None if rule_el is None else rule_element_to_dto(rule_el), + [ + nvpair_element_to_dto(nvpair_el) + for nvpair_el in nvset_el.iterfind("./nvpair") + ], + ) + + +def find_nvsets(parent_element: Element) -> List[Element]: + """ + Get all nvset xml elements in the given parent element + + parent_element -- an element to look for nvsets in + """ + return cast( + # The xpath method has a complicated return value, but we know our xpath + # expression returns only elements. + List[Element], + cast(_Element, parent_element).xpath( + "./*[{nvset_tags}]".format( + nvset_tags=" or ".join(f"self::{tag}" for tag in _tag_to_type) + ) + ), + ) + + +def find_nvsets_by_ids( + parent_element: Element, id_list: Iterable[str] +) -> Tuple[List[Element], ReportItemList]: + """ + Find nvset elements by their IDs and return them with non-empty report + list in case of errors. + + parent_element -- an element to look for nvsets in + id_list -- nvset IDs to be looked for + """ + element_list = [] + report_list: ReportItemList = [] + for nvset_id in id_list: + searcher = ElementSearcher( + _tag_to_type.keys(), + nvset_id, + parent_element, + element_type_desc="options set", + ) + if searcher.element_found(): + element_list.append(searcher.get_element()) + else: + report_list.extend(searcher.get_errors()) + return element_list, report_list + + +class ValidateNvsetAppendNew: + """ + Validator for creating new nvset and appending it to CIB + """ + + def __init__( + self, + id_provider: IdProvider, + nvpair_dict: Mapping[str, str], + nvset_options: Mapping[str, str], + nvset_rule: Optional[str] = None, + rule_allows_rsc_expr: bool = False, + rule_allows_op_expr: bool = False, + ): + """ + id_provider -- elements' ids generator + nvpair_dict -- nvpairs to be put into the new nvset + nvset_options -- additional attributes of the created nvset + nvset_rule -- optional rule describing when the created nvset applies + rule_allows_rsc_expr -- is rsc_expression element allowed in nvset_rule? + rule_allows_op_expr -- is op_expression element allowed in nvset_rule? + """ + self._id_provider = id_provider + self._nvpair_dict = nvpair_dict + self._nvset_options = nvset_options + self._nvset_rule = nvset_rule + self._allow_rsc_expr = rule_allows_rsc_expr + self._allow_op_expr = rule_allows_op_expr + self._nvset_rule_parsed: Optional[RuleRoot] = None + + def validate(self, force_options: bool = False) -> reports.ReportItemList: + report_list: reports.ReportItemList = [] + + # Nvpair dict is intentionally not validated: it may contain any keys + # and values. This can change in the future and then we add a + # validation. Until then there is really nothing to validate there. + + # validate nvset options + validators = [ + validate.NamesIn( + ("id", "score"), + **validate.set_warning( + reports.codes.FORCE_OPTIONS, force_options + ), + ), + # with id_provider it validates that the id is available as well + validate.ValueId( + "id", option_name_for_report="id", id_provider=self._id_provider + ), + validate.ValueScore("score"), + ] + report_list.extend( + validate.ValidatorAll(validators).validate(self._nvset_options) + ) + + # parse and validate rule + # TODO write and call parsed rule validation and cleanup and tests + if self._nvset_rule: + try: + # Allow flags are set to True always, the parsed rule tree is + # checked in the validator instead. That gives us better error + # messages, such as "op expression cannot be used in this + # context" instead of a universal "parse error". + self._nvset_rule_parsed = parse_rule( + self._nvset_rule, allow_rsc_expr=True, allow_op_expr=True + ) + report_list.extend( + RuleValidator( + self._nvset_rule_parsed, + allow_rsc_expr=self._allow_rsc_expr, + allow_op_expr=self._allow_op_expr, + ).get_reports() + ) + except RuleParseError as e: + report_list.append( + reports.ReportItem.error( + reports.messages.RuleExpressionParseError( + e.rule_string, + e.msg, + e.rule_line, + e.lineno, + e.colno, + e.pos, + ) + ) + ) + + return report_list + + def get_parsed_rule(self) -> Optional[RuleRoot]: + return self._nvset_rule_parsed + + +def nvset_append_new( + parent_element: Element, + id_provider: IdProvider, + nvset_tag: NvsetTag, + nvpair_dict: Mapping[str, str], + nvset_options: Mapping[str, str], + nvset_rule: Optional[RuleRoot] = None, +) -> Element: + """ + Create new nvset and append it to CIB + + parent_element -- the created nvset will be appended into this element + id_provider -- elements' ids generator + nvset_tag -- type and actual tag of the nvset + nvpair_dict -- nvpairs to be put into the new nvset + nvset_options -- additional attributes of the created nvset + nvset_rule -- optional rule describing when the created nvset applies + """ + nvset_options = dict(nvset_options) # make a copy which we can modify + if "id" not in nvset_options or not nvset_options["id"]: + nvset_options["id"] = create_subelement_id( + parent_element, nvset_tag, id_provider + ) + + nvset_el = etree.SubElement(cast(_Element, parent_element), nvset_tag) + for name, value in nvset_options.items(): + if value != "": + nvset_el.attrib[name] = value + if nvset_rule: + rule_to_cib(cast(Element, nvset_el), id_provider, nvset_rule) + for name, value in nvpair_dict.items(): + _set_nvpair(cast(Element, nvset_el), id_provider, name, value) + return cast(Element, nvset_el) + + +def nvset_remove(nvset_el_list: Iterable[Element]) -> None: + """ + Remove given nvset elements from CIB + + nvset_el_list -- nvset elements to be removed + """ + for nvset_el in nvset_el_list: + remove_one_element(nvset_el) + + +def nvset_update( + nvset_el: Element, id_provider: IdProvider, nvpair_dict: Mapping[str, str], +) -> None: + """ + Update an existing nvset + + nvset_el -- nvset to be updated + id_provider -- elements' ids generator + nvpair_dict -- nvpairs to be put into the nvset + """ + # Do not ever remove the nvset element, even if it is empty. There may be + # ACLs set in pacemaker which allow "write" for nvpairs (adding, changing + # and removing) but not nvsets. In such a case, removing the nvset would + # cause the whole change to be rejected by pacemaker with a "permission + # denied" message. + # https://bugzilla.redhat.com/show_bug.cgi?id=1642514 + for name, value in nvpair_dict.items(): + _set_nvpair(nvset_el, id_provider, name, value) + + +def _set_nvpair( + nvset_element: Element, id_provider: IdProvider, name: str, value: str +): + """ + Ensure name-value pair is set / removed in specified nvset + + nvset_element -- container for nvpair elements to update + id_provider -- elements' ids generator + name -- name of the nvpair to be set + value -- value of the nvpair to be set, if "" the nvpair will be removed + """ + nvpair_el_list = cast( + # The xpath method has a complicated return value, but we know our xpath + # expression returns only elements. + List[Element], + cast(_Element, nvset_element).xpath("./nvpair[@name=$name]", name=name), + ) + + if not nvpair_el_list: + if value != "": + etree.SubElement( + cast(_Element, nvset_element), + "nvpair", + { + "id": create_subelement_id( + nvset_element, + # limit id length to prevent excessively long ids + name[:20], + id_provider, + ), + "name": name, + "value": value, + }, + ) + return + + if value != "": + nvpair_el_list[0].set("value", value) + else: + nvset_element.remove(nvpair_el_list[0]) + for nvpair_el in nvpair_el_list[1:]: + nvset_element.remove(nvpair_el) diff --git a/pcs/lib/cib/rule/__init__.py b/pcs/lib/cib/rule/__init__.py new file mode 100644 index 00000000..94228572 --- /dev/null +++ b/pcs/lib/cib/rule/__init__.py @@ -0,0 +1,8 @@ +from .cib_to_dto import rule_element_to_dto +from .expression_part import BoolExpr as RuleRoot +from .parser import ( + parse_rule, + RuleParseError, +) +from .parsed_to_cib import export as rule_to_cib +from .validator import Validator as RuleValidator diff --git a/pcs/lib/cib/rule/cib_to_dto.py b/pcs/lib/cib/rule/cib_to_dto.py new file mode 100644 index 00000000..d8198e0c --- /dev/null +++ b/pcs/lib/cib/rule/cib_to_dto.py @@ -0,0 +1,185 @@ +from typing import cast +from xml.etree.ElementTree import Element + +from lxml.etree import _Element + +from pcs.common.pacemaker.rule import ( + CibRuleDateCommonDto, + CibRuleExpressionDto, +) +from pcs.common.str_tools import ( + format_name_value_list, + quote, +) +from pcs.common.types import CibRuleExpressionType +from pcs.lib.xml_tools import export_attributes + + +def rule_element_to_dto(rule_el: Element) -> CibRuleExpressionDto: + """ + Export a rule xml element including its children to their DTOs + """ + return _tag_to_export[rule_el.tag](rule_el) + + +def _attrs_to_str(el: Element) -> str: + return " ".join( + format_name_value_list( + sorted(export_attributes(el, with_id=False).items()) + ) + ) + + +def _rule_to_dto(rule_el: Element) -> CibRuleExpressionDto: + children_dto_list = [ + _tag_to_export[child.tag](child) + # The xpath method has a complicated return value, but we know our xpath + # expression only returns elements. + for child in cast( + Element, cast(_Element, rule_el).xpath(_xpath_for_export) + ) + ] + # "and" is a documented pacemaker default + # https://clusterlabs.org/pacemaker/doc/en-US/Pacemaker/2.0/html-single/Pacemaker_Explained/index.html#_rule_properties + boolean_op = rule_el.get("boolean-op", "and") + string_parts = [] + for child_dto in children_dto_list: + if child_dto.type == CibRuleExpressionType.RULE: + string_parts.append(f"({child_dto.as_string})") + else: + string_parts.append(child_dto.as_string) + return CibRuleExpressionDto( + rule_el.get("id", ""), + _tag_to_type[rule_el.tag], + False, # TODO implement is_expired + export_attributes(rule_el, with_id=False), + None, + None, + children_dto_list, + f" {boolean_op} ".join(string_parts), + ) + + +def _common_expr_to_dto( + expr_el: Element, as_string: str +) -> CibRuleExpressionDto: + return CibRuleExpressionDto( + expr_el.get("id", ""), + _tag_to_type[expr_el.tag], + False, + export_attributes(expr_el, with_id=False), + None, + None, + [], + as_string, + ) + + +def _simple_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto: + string_parts = [] + if "value" in expr_el.attrib: + # "attribute" and "operation" are defined as mandatory in CIB schema + string_parts.extend( + [expr_el.get("attribute", ""), expr_el.get("operation", "")] + ) + if "type" in expr_el.attrib: + string_parts.append(expr_el.get("type", "")) + string_parts.append(quote(expr_el.get("value", ""), " ")) + else: + # "attribute" and "operation" are defined as mandatory in CIB schema + string_parts.extend( + [expr_el.get("operation", ""), expr_el.get("attribute", "")] + ) + return _common_expr_to_dto(expr_el, " ".join(string_parts)) + + +def _date_common_to_dto(expr_el: Element) -> CibRuleDateCommonDto: + return CibRuleDateCommonDto( + expr_el.get("id", ""), export_attributes(expr_el, with_id=False), + ) + + +def _date_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto: + date_spec = expr_el.find("./date_spec") + duration = expr_el.find("./duration") + + string_parts = [] + # "operation" is defined as mandatory in CIB schema + operation = expr_el.get("operation", "") + if operation == "date_spec": + string_parts.append("date-spec") + if date_spec is not None: + string_parts.append(_attrs_to_str(date_spec)) + elif operation == "in_range": + string_parts.extend(["date", "in_range"]) + # CIB schema allows "start" + "duration" or optional "start" + "end" + if "start" in expr_el.attrib: + string_parts.extend([expr_el.get("start", ""), "to"]) + if "end" in expr_el.attrib: + string_parts.append(expr_el.get("end", "")) + if duration is not None: + string_parts.append("duration") + string_parts.append(_attrs_to_str(duration)) + else: + # CIB schema allows operation=="gt" + "start" or operation=="lt" + "end" + string_parts.extend(["date", expr_el.get("operation", "")]) + if "start" in expr_el.attrib: + string_parts.append(expr_el.get("start", "")) + if "end" in expr_el.attrib: + string_parts.append(expr_el.get("end", "")) + + return CibRuleExpressionDto( + expr_el.get("id", ""), + _tag_to_type[expr_el.tag], + False, + export_attributes(expr_el, with_id=False), + None if date_spec is None else _date_common_to_dto(date_spec), + None if duration is None else _date_common_to_dto(duration), + [], + " ".join(string_parts), + ) + + +def _op_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto: + string_parts = ["op"] + string_parts.append(expr_el.get("name", "")) + if "interval" in expr_el.attrib: + string_parts.append( + "interval={interval}".format(interval=expr_el.get("interval", "")) + ) + return _common_expr_to_dto(expr_el, " ".join(string_parts)) + + +def _rsc_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto: + return _common_expr_to_dto( + expr_el, + ( + "resource " + + ":".join( + [ + expr_el.get(attr, "") + for attr in ["class", "provider", "type"] + ] + ) + ), + ) + + +_tag_to_type = { + "rule": CibRuleExpressionType.RULE, + "expression": CibRuleExpressionType.EXPRESSION, + "date_expression": CibRuleExpressionType.DATE_EXPRESSION, + "op_expression": CibRuleExpressionType.OP_EXPRESSION, + "rsc_expression": CibRuleExpressionType.RSC_EXPRESSION, +} + +_tag_to_export = { + "rule": _rule_to_dto, + "expression": _simple_expr_to_dto, + "date_expression": _date_expr_to_dto, + "op_expression": _op_expr_to_dto, + "rsc_expression": _rsc_expr_to_dto, +} +_xpath_for_export = "./*[{export_tags}]".format( + export_tags=" or ".join(f"self::{tag}" for tag in _tag_to_export) +) diff --git a/pcs/lib/cib/rule/expression_part.py b/pcs/lib/cib/rule/expression_part.py new file mode 100644 index 00000000..3ba63aa2 --- /dev/null +++ b/pcs/lib/cib/rule/expression_part.py @@ -0,0 +1,49 @@ +""" +Provides classes used as nodes of a semantic tree of a parsed rule expression. +""" +from dataclasses import dataclass +from typing import ( + NewType, + Optional, + Sequence, +) + + +class RuleExprPart: + pass + + +BoolOperator = NewType("BoolOperator", str) +BOOL_AND = BoolOperator("AND") +BOOL_OR = BoolOperator("OR") + + +@dataclass(frozen=True) +class BoolExpr(RuleExprPart): + """ + Represents a rule combining RuleExprPart objects by AND or OR operation. + """ + + operator: BoolOperator + children: Sequence[RuleExprPart] + + +@dataclass(frozen=True) +class RscExpr(RuleExprPart): + """ + Represents a resource expression in a rule. + """ + + standard: Optional[str] + provider: Optional[str] + type: Optional[str] + + +@dataclass(frozen=True) +class OpExpr(RuleExprPart): + """ + Represents an op expression in a rule. + """ + + name: str + interval: Optional[str] diff --git a/pcs/lib/cib/rule/parsed_to_cib.py b/pcs/lib/cib/rule/parsed_to_cib.py new file mode 100644 index 00000000..0fcae4f1 --- /dev/null +++ b/pcs/lib/cib/rule/parsed_to_cib.py @@ -0,0 +1,103 @@ +from typing import cast +from xml.etree.ElementTree import Element + +from lxml import etree +from lxml.etree import _Element + +from pcs.lib.cib.tools import ( + IdProvider, + create_subelement_id, +) + +from .expression_part import ( + BoolExpr, + OpExpr, + RscExpr, + RuleExprPart, +) + + +def export( + parent_el: Element, id_provider: IdProvider, expr_tree: BoolExpr, +) -> Element: + """ + Export parsed rule to a CIB element + + parent_el -- element to place the rule into + id_provider -- elements' ids generator + expr_tree -- parsed rule tree root + """ + element = __export_part(parent_el, expr_tree, id_provider) + # Add score only to the top level rule element (which is represented by + # BoolExpr class). This is achieved by this function not being called for + # child nodes. + # TODO This was implemented originaly only for rules in resource and + # operation defaults. In those cases, score is the only rule attribute and + # it is always INFINITY. Once this code is used for other rules, modify + # this behavior as needed. + if isinstance(expr_tree, BoolExpr): + element.set("score", "INFINITY") + return element + + +def __export_part( + parent_el: Element, expr_tree: RuleExprPart, id_provider: IdProvider +) -> Element: + part_export_map = { + BoolExpr: __export_bool, + OpExpr: __export_op, + RscExpr: __export_rsc, + } + func = part_export_map[type(expr_tree)] + # mypy doesn't handle this dynamic call + return func(parent_el, expr_tree, id_provider) # type: ignore + + +def __export_bool( + parent_el: Element, boolean: BoolExpr, id_provider: IdProvider +) -> Element: + element = etree.SubElement( + cast(_Element, parent_el), + "rule", + { + "id": create_subelement_id(parent_el, "rule", id_provider), + "boolean-op": boolean.operator.lower(), + }, + ) + for child in boolean.children: + __export_part(cast(Element, element), child, id_provider) + return cast(Element, element) + + +def __export_op( + parent_el: Element, op: OpExpr, id_provider: IdProvider +) -> Element: + element = etree.SubElement( + cast(_Element, parent_el), + "op_expression", + { + "id": create_subelement_id(parent_el, f"op-{op.name}", id_provider), + "name": op.name, + }, + ) + if op.interval: + element.attrib["interval"] = op.interval + return cast(Element, element) + + +def __export_rsc( + parent_el: Element, rsc: RscExpr, id_provider: IdProvider +) -> Element: + id_part = "-".join(filter(None, [rsc.standard, rsc.provider, rsc.type])) + element = etree.SubElement( + cast(_Element, parent_el), + "rsc_expression", + {"id": create_subelement_id(parent_el, f"rsc-{id_part}", id_provider)}, + ) + if rsc.standard: + element.attrib["class"] = rsc.standard + if rsc.provider: + element.attrib["provider"] = rsc.provider + if rsc.type: + element.attrib["type"] = rsc.type + return cast(Element, element) diff --git a/pcs/lib/cib/rule/parser.py b/pcs/lib/cib/rule/parser.py new file mode 100644 index 00000000..2215c524 --- /dev/null +++ b/pcs/lib/cib/rule/parser.py @@ -0,0 +1,232 @@ +from typing import ( + Any, + Iterator, + Optional, + Tuple, +) + +import pyparsing + +from .expression_part import ( + BOOL_AND, + BOOL_OR, + BoolExpr, + OpExpr, + RscExpr, + RuleExprPart, +) + +pyparsing.ParserElement.enablePackrat() + + +class RuleParseError(Exception): + def __init__( + self, + rule_string: str, + rule_line: str, + lineno: int, + colno: int, + pos: int, + msg: str, + ): + super().__init__() + self.rule_string = rule_string + self.rule_line = rule_line + self.lineno = lineno + self.colno = colno + self.pos = pos + self.msg = msg + + +def parse_rule( + rule_string: str, allow_rsc_expr: bool = False, allow_op_expr: bool = False +) -> BoolExpr: + """ + Parse a rule string and return a corresponding semantic tree + + rule_string -- the whole rule expression + allow_rsc_expr -- allow resource expressions in the rule + allow_op_expr -- allow resource operation expressions in the rule + """ + if not rule_string: + return BoolExpr(BOOL_AND, []) + + try: + parsed = __get_rule_parser( + allow_rsc_expr=allow_rsc_expr, allow_op_expr=allow_op_expr + ).parseString(rule_string, parseAll=True)[0] + except pyparsing.ParseException as e: + raise RuleParseError( + rule_string, e.line, e.lineno, e.col, e.loc, e.args[2], + ) + + if not isinstance(parsed, BoolExpr): + # If we only got a representation on an inner rule element instead of a + # rule element itself, wrap the result in a default AND-rule. (There is + # only one expression so "and" vs. "or" doesn't really matter.) + parsed = BoolExpr(BOOL_AND, [parsed]) + + return parsed + + +def __operator_operands( + token_list: pyparsing.ParseResults, +) -> Iterator[Tuple[Any, Any]]: + # See pyparsing examples + # https://github.com/pyparsing/pyparsing/blob/master/examples/eval_arith.py + token_iterator = iter(token_list) + while True: + try: + yield (next(token_iterator), next(token_iterator)) + except StopIteration: + break + + +def __build_bool_tree(token_list: pyparsing.ParseResults) -> RuleExprPart: + # See pyparsing examples + # https://github.com/pyparsing/pyparsing/blob/master/examples/eval_arith.py + token_to_operator = { + "and": BOOL_AND, + "or": BOOL_OR, + } + operand_left = token_list[0][0] + last_operator: Optional[str] = None + operand_list = [] + for operator, operand_right in __operator_operands(token_list[0][1:]): + # In each iteration, we get a bool_op ("and" or "or") and the right + # operand. + if last_operator == operator or last_operator is None: + # If we got the same operator as last time (or this is the first + # one), stack all the operads so we can put them all into one + # BoolExpr class. + operand_list.append(operand_right) + else: + # The operator has changed. Put all the stacked operands into the + # correct BoolExpr class and start the stacking again. The created + # class is the left operand of the current operator. + operand_left = BoolExpr( + token_to_operator[last_operator], [operand_left] + operand_list + ) + operand_list = [operand_right] + last_operator = operator + if operand_list and last_operator: + # Use any of the remaining stacked operands. + operand_left = BoolExpr( + token_to_operator[last_operator], [operand_left] + operand_list + ) + return operand_left + + +def __build_op_expr(parse_result: pyparsing.ParseResults) -> RuleExprPart: + # Those attr are defined by setResultsName in op_expr grammar rule + return OpExpr( + parse_result.name, + # pyparsing-2.1.0 puts "interval_value" into parse_result.interval as + # defined in the grammar AND it also puts "interval_value" into + # parse_result. pyparsing-2.4.0 only puts "interval_value" into + # parse_result. Not sure why, maybe it's a bug, maybe it's intentional. + parse_result.interval_value if parse_result.interval_value else None, + ) + + +def __build_rsc_expr(parse_result: pyparsing.ParseResults) -> RuleExprPart: + # Those attrs are defined by the regexp in rsc_expr grammar rule + return RscExpr( + parse_result.standard, parse_result.provider, parse_result.type + ) + + +def __get_rule_parser( + allow_rsc_expr: bool = False, allow_op_expr: bool = False +) -> pyparsing.ParserElement: + # This function defines the rule grammar + + # It was created for 'pcs resource [op] defaults' commands to be able to + # set defaults for specified resources and/or operation using rules. When + # implementing that feature, there was no time to reimplement all the other + # rule expressions from old code. The plan is to move old rule parser code + # here once there is time / need to do it. + # How to add other rule expressions: + # 1 Create new grammar rules in a way similar to existing rsc_expr and + # op_expr. Use setName for better description of a grammar when printed. + # Use setResultsName for an easy access to parsed parts. + # 2 Create new classes in expression_part module, probably one for each + # type of expression. Those are data containers holding the parsed data + # independent of the parser. + # 3 Create builders for the new classes and connect them to created + # grammar rules using setParseAction. + # 4 Add the new expressions into simple_expr_list. + # 5 Test and debug the whole thing. + + rsc_expr = pyparsing.And( + [ + pyparsing.CaselessKeyword("resource"), + # resource name + # Up to three parts seperated by ":". The parts can contain any + # characters except whitespace (token separator), ":" (parts + # separator) and "()" (brackets). + pyparsing.Regex( + r"(?P[^\s:()]+)?:(?P[^\s:()]+)?:(?P[^\s:()]+)?" + ).setName(""), + ] + ) + rsc_expr.setParseAction(__build_rsc_expr) + + op_interval = pyparsing.And( + [ + pyparsing.CaselessKeyword("interval"), + # no spaces allowed around the "=" + pyparsing.Literal("=").leaveWhitespace(), + # interval value: number followed by a time unit, no spaces allowed + # between the number and the unit thanks to Combine being used + pyparsing.Combine( + pyparsing.And( + [ + pyparsing.Word(pyparsing.nums), + pyparsing.Optional(pyparsing.Word(pyparsing.alphas)), + ] + ) + ) + .setName("[