diff --git a/.gitignore b/.gitignore index c3430ba..4d3a5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,19 @@ SOURCES/HAM-logo.png SOURCES/backports-3.17.2.gem -SOURCES/dacite-1.5.0.tar.gz +SOURCES/dacite-1.6.0.tar.gz SOURCES/daemons-1.3.1.gem -SOURCES/dataclasses-0.6.tar.gz +SOURCES/dataclasses-0.8.tar.gz SOURCES/ethon-0.12.0.gem SOURCES/eventmachine-1.2.7.gem SOURCES/ffi-1.13.1.gem SOURCES/json-2.3.0.gem SOURCES/mustermann-1.1.1.gem SOURCES/open4-1.3.4-1.gem -SOURCES/pcs-0.10.6.tar.gz -SOURCES/pcs-web-ui-0.1.4.tar.gz -SOURCES/pcs-web-ui-node-modules-0.1.3.tar.xz +SOURCES/pcs-0.10.8.tar.gz +SOURCES/pcs-web-ui-0.1.5.tar.gz +SOURCES/pcs-web-ui-node-modules-0.1.5.tar.xz SOURCES/pyagentx-0.4.pcs.2.tar.gz +SOURCES/python-dateutil-2.8.1.tar.gz SOURCES/rack-2.2.3.gem SOURCES/rack-protection-2.0.8.1.gem SOURCES/rack-test-1.1.0.gem @@ -20,4 +21,4 @@ SOURCES/ruby2_keywords-0.0.2.gem SOURCES/sinatra-2.0.8.1.gem SOURCES/thin-1.7.2.gem SOURCES/tilt-2.0.10.gem -SOURCES/tornado-6.0.4.tar.gz +SOURCES/tornado-6.1.0.tar.gz diff --git a/.pcs.metadata b/.pcs.metadata index e3f6601..1b31355 100644 --- a/.pcs.metadata +++ b/.pcs.metadata @@ -1,18 +1,19 @@ 679a4ce22a33ffd4d704261a17c00cff98d9499a SOURCES/HAM-logo.png 28b63a742124da6c9575a1c5e7d7331ef93600b2 SOURCES/backports-3.17.2.gem -c14ee49221d8e1b09364b5f248bc3da12484f675 SOURCES/dacite-1.5.0.tar.gz +31546c37fbdc6270d5097687619e9c0db6f1c05c SOURCES/dacite-1.6.0.tar.gz e28c1e78d1a6e34e80f4933b494f1e0501939dd3 SOURCES/daemons-1.3.1.gem -81079b734108084eea0ae1c05a1cab0e806a3a1d SOURCES/dataclasses-0.6.tar.gz +8b7598273d2ae6dad2b88466aefac55071a41926 SOURCES/dataclasses-0.8.tar.gz 921ef1be44583a7644ee7f20fe5f26f21d018a04 SOURCES/ethon-0.12.0.gem 7a5b2896e210fac9759c786ee4510f265f75b481 SOURCES/eventmachine-1.2.7.gem cfa25e7a3760c3ec16723cb8263d9b7a52d0eadf SOURCES/ffi-1.13.1.gem 0230e8c5a37f1543982e5b04be503dd5f9004b47 SOURCES/json-2.3.0.gem 50a4e37904485810cb05e27d75c9783e5a8f3402 SOURCES/mustermann-1.1.1.gem 41a7fe9f8e3e02da5ae76c821b89c5b376a97746 SOURCES/open4-1.3.4-1.gem -73fafb4228326c14a799f0cccbcb734ab7ba2bfa SOURCES/pcs-0.10.6.tar.gz -d67de4d5cefd9ba3cde45c7ec4a5d1e9b1e6032a SOURCES/pcs-web-ui-0.1.4.tar.gz -3e09042e3dc32c992451ba4c0454f2879f0d3f40 SOURCES/pcs-web-ui-node-modules-0.1.3.tar.xz +0e6b705715023ec5224ca05e977b8888f2a1b1e6 SOURCES/pcs-0.10.8.tar.gz +f23b14786b1911d498612bf0e90f344bcc4915c3 SOURCES/pcs-web-ui-0.1.5.tar.gz +57beab1c4bed96d7f9fc35261e96f78babb06980 SOURCES/pcs-web-ui-node-modules-0.1.5.tar.xz 3176b2f2b332c2b6bf79fe882e83feecf3d3f011 SOURCES/pyagentx-0.4.pcs.2.tar.gz +bd26127e57f83a10f656b62c46524c15aeb844dd SOURCES/python-dateutil-2.8.1.tar.gz 345b7169d4d2d62176a225510399963bad62b68f SOURCES/rack-2.2.3.gem 1f046e23baca8beece3b38c60382f44aa2b2cb41 SOURCES/rack-protection-2.0.8.1.gem b80bc5ca38a885e747271675ba91dd3d02136bf1 SOURCES/rack-test-1.1.0.gem @@ -20,4 +21,4 @@ b80bc5ca38a885e747271675ba91dd3d02136bf1 SOURCES/rack-test-1.1.0.gem 04cca7a5d9d641fe076e4e24dc5b6ff31922f4c3 SOURCES/sinatra-2.0.8.1.gem 41395e86322ffd31f3a7aef1f697bda3e1e2d6b9 SOURCES/thin-1.7.2.gem d265c822a6b228392d899e9eb5114613d65e6967 SOURCES/tilt-2.0.10.gem -e177f2a092dc5f23b0b3078e40adf52e17a9f8a6 SOURCES/tornado-6.0.4.tar.gz +c23c617c7a0205e465bebad5b8cdf289ae8402a2 SOURCES/tornado-6.1.0.tar.gz diff --git a/SOURCES/bz1805082-01-fix-resource-stonith-refresh-documentation.patch b/SOURCES/bz1805082-01-fix-resource-stonith-refresh-documentation.patch deleted file mode 100644 index 7703e96..0000000 --- a/SOURCES/bz1805082-01-fix-resource-stonith-refresh-documentation.patch +++ /dev/null @@ -1,57 +0,0 @@ -From be40fe494ddeb4f7132389ca0f3c1193de0e425d Mon Sep 17 00:00:00 2001 -From: Tomas Jelinek -Date: Tue, 23 Jun 2020 12:57:05 +0200 -Subject: [PATCH 2/3] fix 'resource | stonith refresh' documentation - ---- - pcs/pcs.8 | 4 ++-- - pcs/usage.py | 4 ++-- - 2 files changed, 4 insertions(+), 4 deletions(-) - -diff --git a/pcs/pcs.8 b/pcs/pcs.8 -index c887d332..3efc5bb2 100644 ---- a/pcs/pcs.8 -+++ b/pcs/pcs.8 -@@ -325,7 +325,7 @@ If a node is not specified then resources / stonith devices on all nodes will be - refresh [] [node=] [\fB\-\-strict\fR] - Make the cluster forget the complete operation history (including failures) of the resource and re\-detect its current state. If you are interested in forgetting failed operations only, use the 'pcs resource cleanup' command. - .br --If the named resource is part of a group, or one numbered instance of a clone or bundled resource, the clean\-up applies to the whole collective resource unless \fB\-\-strict\fR is given. -+If the named resource is part of a group, or one numbered instance of a clone or bundled resource, the refresh applies to the whole collective resource unless \fB\-\-strict\fR is given. - .br - If a resource id is not specified then all resources / stonith devices will be refreshed. - .br -@@ -613,7 +613,7 @@ If a node is not specified then resources / stonith devices on all nodes will be - refresh [] [\fB\-\-node\fR ] [\fB\-\-strict\fR] - Make the cluster forget the complete operation history (including failures) of the stonith device and re\-detect its current state. If you are interested in forgetting failed operations only, use the 'pcs stonith cleanup' command. - .br --If the named stonith device is part of a group, or one numbered instance of a clone or bundled resource, the clean\-up applies to the whole collective resource unless \fB\-\-strict\fR is given. -+If the named stonith device is part of a group, or one numbered instance of a clone or bundled resource, the refresh applies to the whole collective resource unless \fB\-\-strict\fR is given. - .br - If a stonith id is not specified then all resources / stonith devices will be refreshed. - .br -diff --git a/pcs/usage.py b/pcs/usage.py -index 8722bd7b..0f3c95a3 100644 ---- a/pcs/usage.py -+++ b/pcs/usage.py -@@ -663,7 +663,7 @@ Commands: - interested in forgetting failed operations only, use the 'pcs resource - cleanup' command. - If the named resource is part of a group, or one numbered instance of a -- clone or bundled resource, the clean-up applies to the whole collective -+ clone or bundled resource, the refresh applies to the whole collective - resource unless --strict is given. - If a resource id is not specified then all resources / stonith devices - will be refreshed. -@@ -1214,7 +1214,7 @@ Commands: - are interested in forgetting failed operations only, use the 'pcs - stonith cleanup' command. - If the named stonith device is part of a group, or one numbered -- instance of a clone or bundled resource, the clean-up applies to the -+ instance of a clone or bundled resource, the refresh applies to the - whole collective resource unless --strict is given. - If a stonith id is not specified then all resources / stonith devices - will be refreshed. --- -2.25.4 - diff --git a/SOURCES/bz1817547-01-resource-and-operation-defaults.patch b/SOURCES/bz1817547-01-resource-and-operation-defaults.patch deleted file mode 100644 index 34d1795..0000000 --- a/SOURCES/bz1817547-01-resource-and-operation-defaults.patch +++ /dev/null @@ -1,7605 +0,0 @@ -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("[