diff --git a/.gitignore b/.gitignore index b5198b3..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.3.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 2c6863f..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 -df118954a980ceecc9cdd0e85a83d43253836f7f SOURCES/pcs-web-ui-0.1.3.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 <tojeline@redhat.com> -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 [<resource id>] [node=<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 [<stonith id>] [\fB\-\-node\fR <node>] [\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 <tojeline@redhat.com> -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<standard>[^\s:()]+)?:(?P<provider>[^\s:()]+)?:(?P<type>[^\s:()]+)?" -+ ).setName("<resource name>"), -+ ] -+ ) -+ 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("<integer>[<time unit>]") -+ .setResultsName("interval_value"), -+ ] -+ ) -+ op_expr = pyparsing.And( -+ [ -+ pyparsing.CaselessKeyword("op"), -+ # operation name -+ # It can by any string containing any characters except whitespace -+ # (token separator) and "()" (brackets). Operations are defined in -+ # agents' metadata which we do not have access to (e.g. when the -+ # user sets operation "my_check" and doesn't even specify agent's -+ # name). -+ pyparsing.Regex(r"[^\s()]+") -+ .setName("<operation name>") -+ .setResultsName("name"), -+ pyparsing.Optional(op_interval).setResultsName("interval"), -+ ] -+ ) -+ op_expr.setParseAction(__build_op_expr) -+ -+ simple_expr_list = [] -+ if allow_rsc_expr: -+ simple_expr_list.append(rsc_expr) -+ if allow_op_expr: -+ simple_expr_list.append(op_expr) -+ simple_expr = pyparsing.Or(simple_expr_list) -+ -+ # See pyparsing examples -+ # https://github.com/pyparsing/pyparsing/blob/master/examples/simpleBool.py -+ # https://github.com/pyparsing/pyparsing/blob/master/examples/eval_arith.py -+ bool_operator = pyparsing.Or( -+ [pyparsing.CaselessKeyword("and"), pyparsing.CaselessKeyword("or")] -+ ) -+ bool_expr = pyparsing.infixNotation( -+ simple_expr, -+ # By putting both "and" and "or" in one tuple we say they have the same -+ # priority. This is consistent with legacy pcs parsers. And it is how -+ # it should be, they work as a glue between "simple_expr"s. -+ [(bool_operator, 2, pyparsing.opAssoc.LEFT, __build_bool_tree)], -+ ) -+ -+ return pyparsing.Or([bool_expr, simple_expr]) -diff --git a/pcs/lib/cib/rule/validator.py b/pcs/lib/cib/rule/validator.py -new file mode 100644 -index 00000000..c733ad96 ---- /dev/null -+++ b/pcs/lib/cib/rule/validator.py -@@ -0,0 +1,62 @@ -+from typing import Set -+ -+from pcs.common import reports -+from pcs.common.types import CibRuleExpressionType -+ -+from .expression_part import ( -+ BoolExpr, -+ OpExpr, -+ RscExpr, -+) -+ -+ -+class Validator: -+ # TODO For now we only check allowed expressions. Other checks and -+ # validations can be added if needed. -+ def __init__( -+ self, -+ parsed_rule: BoolExpr, -+ allow_rsc_expr: bool = False, -+ allow_op_expr: bool = False, -+ ): -+ """ -+ parsed_rule -- a rule to be validated -+ allow_op_expr -- are op expressions allowed in the rule? -+ allow_rsc_expr -- are resource expressions allowed in the rule? -+ """ -+ self._rule = parsed_rule -+ self._allow_op_expr = allow_op_expr -+ self._allow_rsc_expr = allow_rsc_expr -+ self._disallowed_expr_list: Set[CibRuleExpressionType] = set() -+ -+ self._method_map = { -+ BoolExpr: self._validate_bool_expr, -+ OpExpr: self._validate_op_expr, -+ RscExpr: self._validate_rsc_expr, -+ } -+ -+ def get_reports(self) -> reports.ReportItemList: -+ self._method_map[type(self._rule)](self._rule) -+ report_list = [] -+ for expr_type in self._disallowed_expr_list: -+ report_list.append( -+ reports.ReportItem.error( -+ reports.messages.RuleExpressionNotAllowed(expr_type) -+ ) -+ ) -+ return report_list -+ -+ def _validate_bool_expr(self, expr: BoolExpr): -+ for child in expr.children: -+ if type(child) in self._method_map: -+ self._method_map[type(child)](child) -+ -+ def _validate_op_expr(self, expr): -+ del expr -+ if not self._allow_op_expr: -+ self._disallowed_expr_list.add(CibRuleExpressionType.OP_EXPRESSION) -+ -+ def _validate_rsc_expr(self, expr): -+ del expr -+ if not self._allow_rsc_expr: -+ self._disallowed_expr_list.add(CibRuleExpressionType.RSC_EXPRESSION) -diff --git a/pcs/lib/cib/tools.py b/pcs/lib/cib/tools.py -index 920b7442..cfc5ba59 100644 ---- a/pcs/lib/cib/tools.py -+++ b/pcs/lib/cib/tools.py -@@ -28,7 +28,7 @@ class IdProvider: - self._cib = get_root(cib_element) - self._booked_ids = set() - -- def allocate_id(self, proposed_id): -+ def allocate_id(self, proposed_id: str) -> str: - """ - Generate a new unique id based on the proposal and keep track of it - string proposed_id -- requested id -@@ -294,9 +294,11 @@ def find_element_by_tag_and_id( - return None - - --def create_subelement_id(context_element, suffix, id_provider): -+def create_subelement_id( -+ context_element: Element, suffix: str, id_provider: IdProvider -+) -> str: - proposed_id = sanitize_id( -- "{0}-{1}".format(context_element.get("id"), suffix) -+ "{0}-{1}".format(context_element.get("id", context_element.tag), suffix) - ) - return id_provider.allocate_id(proposed_id) - -diff --git a/pcs/lib/commands/cib_options.py b/pcs/lib/commands/cib_options.py -index 713644ca..368ce409 100644 ---- a/pcs/lib/commands/cib_options.py -+++ b/pcs/lib/commands/cib_options.py -@@ -1,54 +1,312 @@ --from functools import partial -+from typing import ( -+ Any, -+ Container, -+ Iterable, -+ List, -+ Mapping, -+ Optional, -+) - - from pcs.common import reports -+from pcs.common.pacemaker.nvset import CibNvsetDto - from pcs.common.reports.item import ReportItem --from pcs.lib.cib import sections --from pcs.lib.cib.nvpair import arrange_first_meta_attributes -+from pcs.common.tools import Version -+from pcs.lib.cib import ( -+ nvpair_multi, -+ sections, -+) - from pcs.lib.cib.tools import IdProvider - from pcs.lib.env import LibraryEnvironment -+from pcs.lib.errors import LibraryError - - --def _set_any_defaults(section_name, env: LibraryEnvironment, options): -+def resource_defaults_create( -+ env: LibraryEnvironment, -+ nvpairs: Mapping[str, str], -+ nvset_options: Mapping[str, str], -+ nvset_rule: Optional[str] = None, -+ force_flags: Optional[Container] = None, -+) -> None: - """ -- string section_name -- determine the section of defaults -- env -- provides access to outside environment -- dict options -- are desired options with its values; when value is empty the -- option have to be removed -+ Create new resource defaults nvset -+ -+ env -- -+ nvpairs -- name-value pairs 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 -+ force_flags -- list of flags codes -+ """ -+ return _defaults_create( -+ env, -+ sections.RSC_DEFAULTS, -+ dict(rule_allows_rsc_expr=True, rule_allows_op_expr=False), -+ nvpairs, -+ nvset_options, -+ nvset_rule=nvset_rule, -+ force_flags=force_flags, -+ ) -+ -+ -+def operation_defaults_create( -+ env: LibraryEnvironment, -+ nvpairs: Mapping[str, str], -+ nvset_options: Mapping[str, str], -+ nvset_rule: Optional[str] = None, -+ force_flags: Optional[Container] = None, -+) -> None: -+ """ -+ Create new operation defaults nvset -+ -+ env -- -+ nvpairs -- name-value pairs 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 -+ force_flags -- list of flags codes - """ -- # 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 -+ return _defaults_create( -+ env, -+ sections.OP_DEFAULTS, -+ dict(rule_allows_rsc_expr=True, rule_allows_op_expr=True), -+ nvpairs, -+ nvset_options, -+ nvset_rule=nvset_rule, -+ force_flags=force_flags, -+ ) -+ -+ -+def _defaults_create( -+ env: LibraryEnvironment, -+ cib_section_name: str, -+ validator_options: Mapping[str, Any], -+ nvpairs: Mapping[str, str], -+ nvset_options: Mapping[str, str], -+ nvset_rule: Optional[str] = None, -+ force_flags: Optional[Container] = None, -+) -> None: -+ if force_flags is None: -+ force_flags = set() -+ force = (reports.codes.FORCE in force_flags) or ( -+ reports.codes.FORCE_OPTIONS in force_flags -+ ) -+ -+ required_cib_version = None -+ if nvset_rule: -+ required_cib_version = Version(3, 4, 0) -+ cib = env.get_cib(required_cib_version) -+ id_provider = IdProvider(cib) -+ -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ id_provider, -+ nvpairs, -+ nvset_options, -+ nvset_rule=nvset_rule, -+ **validator_options, -+ ) -+ if env.report_processor.report_list( -+ validator.validate(force_options=force) -+ ).has_errors: -+ raise LibraryError() -+ -+ nvpair_multi.nvset_append_new( -+ sections.get(cib, cib_section_name), -+ id_provider, -+ nvpair_multi.NVSET_META, -+ nvpairs, -+ nvset_options, -+ nvset_rule=validator.get_parsed_rule(), -+ ) -+ - env.report_processor.report( - ReportItem.warning(reports.messages.DefaultsCanBeOverriden()) - ) -+ env.push_cib() -+ -+ -+def resource_defaults_config(env: LibraryEnvironment) -> List[CibNvsetDto]: -+ """ -+ List all resource defaults nvsets -+ """ -+ return _defaults_config(env, sections.RSC_DEFAULTS) -+ -+ -+def operation_defaults_config(env: LibraryEnvironment) -> List[CibNvsetDto]: -+ """ -+ List all operation defaults nvsets -+ """ -+ return _defaults_config(env, sections.OP_DEFAULTS) -+ -+ -+def _defaults_config( -+ env: LibraryEnvironment, cib_section_name: str, -+) -> List[CibNvsetDto]: -+ return [ -+ nvpair_multi.nvset_element_to_dto(nvset_el) -+ for nvset_el in nvpair_multi.find_nvsets( -+ sections.get(env.get_cib(), cib_section_name) -+ ) -+ ] -+ -+ -+def resource_defaults_remove( -+ env: LibraryEnvironment, nvset_id_list: Iterable[str] -+) -> None: -+ """ -+ Remove specified resource defaults nvsets -+ -+ env -- -+ nvset_id_list -- nvset IDs to be removed -+ """ -+ return _defaults_remove(env, sections.RSC_DEFAULTS, nvset_id_list) -+ -+ -+def operation_defaults_remove( -+ env: LibraryEnvironment, nvset_id_list: Iterable[str] -+) -> None: -+ """ -+ Remove specified operation defaults nvsets - -- if not options: -+ env -- -+ nvset_id_list -- nvset IDs to be removed -+ """ -+ return _defaults_remove(env, sections.OP_DEFAULTS, nvset_id_list) -+ -+ -+def _defaults_remove( -+ env: LibraryEnvironment, cib_section_name: str, nvset_id_list: Iterable[str] -+) -> None: -+ if not nvset_id_list: - return -+ nvset_elements, report_list = nvpair_multi.find_nvsets_by_ids( -+ sections.get(env.get_cib(), cib_section_name), nvset_id_list -+ ) -+ if env.report_processor.report_list(report_list).has_errors: -+ raise LibraryError() -+ nvpair_multi.nvset_remove(nvset_elements) -+ env.push_cib() -+ -+ -+def resource_defaults_update( -+ env: LibraryEnvironment, -+ nvset_id: Optional[str], -+ nvpairs: Mapping[str, str], -+) -> None: -+ """ -+ Update specified resource defaults nvset -+ -+ env -- -+ nvset_id -- nvset ID to be updated; if None, update an existing nvset if -+ there is only one -+ nvpairs -- name-value pairs to be put into the nvset -+ """ -+ return _defaults_update( -+ env, -+ sections.RSC_DEFAULTS, -+ nvset_id, -+ nvpairs, -+ reports.const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE, -+ ) -+ - -+def operation_defaults_update( -+ env: LibraryEnvironment, -+ nvset_id: Optional[str], -+ nvpairs: Mapping[str, str], -+) -> None: -+ """ -+ Update specified operation defaults nvset -+ -+ env -- -+ nvset_id -- nvset ID to be updated; if None, update an existing nvset if -+ there is only one -+ nvpairs -- name-value pairs to be put into the nvset -+ """ -+ return _defaults_update( -+ env, -+ sections.OP_DEFAULTS, -+ nvset_id, -+ nvpairs, -+ reports.const.PCS_COMMAND_OPERATION_DEFAULTS_UPDATE, -+ ) -+ -+ -+def _defaults_update( -+ env: LibraryEnvironment, -+ cib_section_name: str, -+ nvset_id: Optional[str], -+ nvpairs: Mapping[str, str], -+ pcs_command: reports.types.PcsCommand, -+) -> None: - cib = env.get_cib() -+ id_provider = IdProvider(cib) -+ -+ if nvset_id is None: -+ # Backward compatibility code to support an old use case where no id -+ # was requested and provided and the first meta_attributes nvset was -+ # created / updated. However, we check that there is only one nvset -+ # present in the CIB to prevent breaking the configuration with -+ # multiple nvsets in place. -+ -+ # This is to be supported as it provides means of easily managing -+ # defaults if only one set of defaults is needed. - -- # Do not create new defaults element if we are only removing values from it. -- only_removing = True -- for value in options.values(): -- if value != "": -- only_removing = False -- break -- if only_removing and not sections.exists(cib, section_name): -+ # TODO move this to a separate lib command. -+ -+ if not nvpairs: -+ return -+ -+ # Do not create new defaults element if we are only removing values -+ # from it. -+ only_removing = True -+ for value in nvpairs.values(): -+ if value != "": -+ only_removing = False -+ break -+ if only_removing and not sections.exists(cib, cib_section_name): -+ env.report_processor.report( -+ ReportItem.warning(reports.messages.DefaultsCanBeOverriden()) -+ ) -+ return -+ -+ nvset_elements = nvpair_multi.find_nvsets( -+ sections.get(cib, cib_section_name) -+ ) -+ if len(nvset_elements) > 1: -+ env.report_processor.report( -+ reports.item.ReportItem.error( -+ reports.messages.CibNvsetAmbiguousProvideNvsetId( -+ pcs_command -+ ) -+ ) -+ ) -+ raise LibraryError() -+ env.report_processor.report( -+ ReportItem.warning(reports.messages.DefaultsCanBeOverriden()) -+ ) -+ if len(nvset_elements) == 1: -+ nvpair_multi.nvset_update(nvset_elements[0], id_provider, nvpairs) -+ elif only_removing: -+ # do not create new nvset if there is none and we are only removing -+ # nvpairs -+ return -+ else: -+ nvpair_multi.nvset_append_new( -+ sections.get(cib, cib_section_name), -+ id_provider, -+ nvpair_multi.NVSET_META, -+ nvpairs, -+ {}, -+ ) -+ env.push_cib() - return - -- defaults_section = sections.get(cib, section_name) -- arrange_first_meta_attributes( -- defaults_section, -- options, -- IdProvider(cib), -- new_id="{0}-options".format(section_name), -+ nvset_elements, report_list = nvpair_multi.find_nvsets_by_ids( -+ sections.get(cib, cib_section_name), [nvset_id] - ) -+ if env.report_processor.report_list(report_list).has_errors: -+ raise LibraryError() - -+ nvpair_multi.nvset_update(nvset_elements[0], id_provider, nvpairs) -+ env.report_processor.report( -+ ReportItem.warning(reports.messages.DefaultsCanBeOverriden()) -+ ) - env.push_cib() -- -- --set_operations_defaults = partial(_set_any_defaults, sections.OP_DEFAULTS) --set_resources_defaults = partial(_set_any_defaults, sections.RSC_DEFAULTS) -diff --git a/pcs/lib/validate.py b/pcs/lib/validate.py -index 2edf8b31..7890585a 100644 ---- a/pcs/lib/validate.py -+++ b/pcs/lib/validate.py -@@ -39,6 +39,7 @@ from pcs.common.reports import ( - ) - from pcs.lib.corosync import constants as corosync_constants - from pcs.lib.pacemaker.values import ( -+ is_score, - timeout_to_seconds, - validate_id, - ) -@@ -676,6 +677,20 @@ class ValuePositiveInteger(ValuePredicateBase): - return "a positive integer" - - -+class ValueScore(ValueValidator): -+ """ -+ Report INVALID_SCORE if the value is not a valid CIB score -+ """ -+ -+ def _validate_value(self, value): -+ report_list = [] -+ if not is_score(value.normalized): -+ report_list.append( -+ ReportItem.error(reports.messages.InvalidScore(value.original)) -+ ) -+ return report_list -+ -+ - class ValueTimeInterval(ValuePredicateBase): - """ - Report INVALID_OPTION_VALUE when the value is not a time interval -diff --git a/pcs/lib/xml_tools.py b/pcs/lib/xml_tools.py -index a463c418..b7d778a3 100644 ---- a/pcs/lib/xml_tools.py -+++ b/pcs/lib/xml_tools.py -@@ -1,4 +1,4 @@ --from typing import cast, Iterable -+from typing import cast, Dict, Iterable - from xml.etree.ElementTree import Element - - from lxml import etree -@@ -56,8 +56,11 @@ def get_sub_element( - return sub_element - - --def export_attributes(element): -- return dict((key, value) for key, value in element.attrib.items()) -+def export_attributes(element: Element, with_id: bool = True) -> Dict[str, str]: -+ result = dict((key, value) for key, value in element.attrib.items()) -+ if not with_id: -+ result.pop("id", None) -+ return result - - - def update_attribute_remove_empty(element, name, value): -diff --git a/pcs/pcs.8 b/pcs/pcs.8 -index 85c6adb1..c887d332 100644 ---- a/pcs/pcs.8 -+++ b/pcs/pcs.8 -@@ -185,8 +185,48 @@ Remove specified operation (note: you must specify the exact operation propertie - op remove <operation id> - Remove the specified operation id. - .TP --op defaults [options] --Set default values for operations, if no options are passed, lists currently configured defaults. Defaults do not apply to resources which override them with their own defined operations. -+op defaults [config] [\fB\-\-full\fR] -+List currently configured default values for operations. If \fB\-\-full\fR is specified, also list ids. -+.TP -+op defaults <name>=<value> -+Set default values for operations. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. -+.TP -+op defaults set create [<set options>] [meta [<name>=<value>]...] [rule [<expression>]] -+Create a new set of default values for resource operations. You may specify a rule describing resources and / or operations to which the set applies. -+.br -+Set options are: id, score -+.br -+Expression looks like one of the following: -+.br -+ op <operation name> [interval=<interval>] -+.br -+ resource [<standard>]:[<provider>]:[<type>] -+.br -+ <expression> and|or <expression> -+.br -+ ( <expression> ) -+.br -+You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. -+.TP -+op defaults set delete [<set id>]... -+Delete specified options sets. -+.TP -+op defaults set remove [<set id>]... -+Delete specified options sets. -+.TP -+op defaults set update <set id> [meta [<name>=<value>]...] -+Add, remove or change values in specified set of default values for resource operations. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. -+.TP -+op defaults update <name>=<value>... -+Set default values for operations. This is a simplified command useful for cases when you only manage one set of default values. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. - .TP - meta <resource id | group id | clone id> <meta options> [\fB\-\-wait\fR[=n]] - Add specified options to the specified resource, group or clone. Meta options should be in the format of name=value, options may be removed by setting an option without a value. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the changes to take effect and then return 0 if the changes have been processed or 1 otherwise. If 'n' is not specified it defaults to 60 minutes. -@@ -232,8 +272,46 @@ Set resources listed to managed mode (default). If \fB\-\-monitor\fR is specifie - unmanage <resource id | tag id>... [\fB\-\-monitor\fR] - Set resources listed to unmanaged mode. When a resource is in unmanaged mode, the cluster is not allowed to start nor stop the resource. If \fB\-\-monitor\fR is specified, disable all monitor operations of the resources. - .TP --defaults [options] --Set default values for resources, if no options are passed, lists currently configured defaults. Defaults do not apply to resources which override them with their own defined values. -+defaults [config] [\fB\-\-full\fR] -+List currently configured default values for resources. If \fB\-\-full\fR is specified, also list ids. -+.TP -+defaults <name>=<value> -+Set default values for resources. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. -+.TP -+defaults set create [<set options>] [meta [<name>=<value>]...] [rule [<expression>]] -+Create a new set of default values for resources. You may specify a rule describing resources to which the set applies. -+.br -+Set options are: id, score -+.br -+Expression looks like one of the following: -+.br -+ resource [<standard>]:[<provider>]:[<type>] -+.br -+ <expression> and|or <expression> -+.br -+ ( <expression> ) -+.br -+You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. -+.TP -+defaults set delete [<set id>]... -+Delete specified options sets. -+.TP -+defaults set remove [<set id>]... -+Delete specified options sets. -+.TP -+defaults set update <set id> [meta [<name>=<value>]...] -+Add, remove or change values in specified set of default values for resources. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. -+.TP -+defaults update <name>=<value>... -+Set default values for resources. This is a simplified command useful for cases when you only manage one set of default values. -+.br -+NOTE: Defaults do not apply to resources which override them with their own defined values. - .TP - cleanup [<resource id>] [node=<node>] [operation=<operation> [interval=<interval>]] [\fB\-\-strict\fR] - Make the cluster forget failed operations from history of the resource and re\-detect its current state. This can be useful to purge knowledge of past failures that have since been resolved. -diff --git a/pcs/resource.py b/pcs/resource.py -index dd199bea..e835fc99 100644 ---- a/pcs/resource.py -+++ b/pcs/resource.py -@@ -6,7 +6,12 @@ import textwrap - import time - import json - --from typing import Any, List -+from typing import ( -+ Any, -+ Callable, -+ List, -+ Sequence, -+) - - from pcs import ( - usage, -@@ -19,10 +24,12 @@ from pcs.settings import ( - ) - from pcs.cli.common.errors import CmdLineInputError, raise_command_replaced - from pcs.cli.common.parse_args import ( -+ group_by_keywords, - prepare_options, - prepare_options_allowed, - InputModifiers, - ) -+from pcs.cli.nvset import nvset_dto_list_to_lines - from pcs.cli.reports import process_library_reports - from pcs.cli.reports.output import error, warn - from pcs.cli.resource.parse_args import ( -@@ -31,8 +38,8 @@ from pcs.cli.resource.parse_args import ( - parse_bundle_update_options, - parse_create as parse_create_args, - ) -+from pcs.common import reports - from pcs.common.str_tools import indent --from pcs.common.reports import ReportItemSeverity - import pcs.lib.cib.acl as lib_acl - from pcs.lib.cib.resource import ( - bundle, -@@ -113,28 +120,228 @@ def resource_utilization_cmd(lib, argv, modifiers): - set_resource_utilization(argv.pop(0), argv) - - --def resource_defaults_cmd(lib, argv, modifiers): -+def _defaults_set_create_cmd( -+ lib_command: Callable[..., Any], -+ argv: Sequence[str], -+ modifiers: InputModifiers, -+): -+ modifiers.ensure_only_supported("-f", "--force") -+ -+ groups = group_by_keywords( -+ argv, -+ set(["meta", "rule"]), -+ implicit_first_group_key="options", -+ keyword_repeat_allowed=False, -+ ) -+ force_flags = set() -+ if modifiers.get("--force"): -+ force_flags.add(reports.codes.FORCE) -+ -+ lib_command( -+ prepare_options(groups["meta"]), -+ prepare_options(groups["options"]), -+ nvset_rule=(" ".join(groups["rule"]) if groups["rule"] else None), -+ force_flags=force_flags, -+ ) -+ -+ -+def resource_defaults_set_create_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ * --force - allow unknown options -+ """ -+ return _defaults_set_create_cmd( -+ lib.cib_options.resource_defaults_create, argv, modifiers -+ ) -+ -+ -+def resource_op_defaults_set_create_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ * --force - allow unknown options -+ """ -+ return _defaults_set_create_cmd( -+ lib.cib_options.operation_defaults_create, argv, modifiers -+ ) -+ -+ -+def _defaults_config_cmd( -+ lib_command: Callable[..., Any], -+ argv: Sequence[str], -+ modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ * --full - verbose output -+ """ -+ if argv: -+ raise CmdLineInputError() -+ modifiers.ensure_only_supported("-f", "--full") -+ print( -+ "\n".join( -+ nvset_dto_list_to_lines( -+ lib_command(), -+ with_ids=modifiers.get("--full"), -+ text_if_empty="No defaults set", -+ ) -+ ) -+ ) -+ -+ -+def resource_defaults_config_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ * --full - verbose output -+ """ -+ return _defaults_config_cmd( -+ lib.cib_options.resource_defaults_config, argv, modifiers -+ ) -+ -+ -+def resource_op_defaults_config_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ * --full - verbose output -+ """ -+ return _defaults_config_cmd( -+ lib.cib_options.operation_defaults_config, argv, modifiers -+ ) -+ -+ -+def _defaults_set_remove_cmd( -+ lib_command: Callable[..., Any], -+ argv: Sequence[str], -+ modifiers: InputModifiers, -+) -> None: - """ - Options: - * -f - CIB file - """ - modifiers.ensure_only_supported("-f") -- if not argv: -- print("\n".join(show_defaults(utils.get_cib_dom(), "rsc_defaults"))) -- else: -- lib.cib_options.set_resources_defaults(prepare_options(argv)) -+ lib_command(argv) - - --def resource_op_defaults_cmd(lib, argv, modifiers): -+def resource_defaults_set_remove_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ """ -+ return _defaults_set_remove_cmd( -+ lib.cib_options.resource_defaults_remove, argv, modifiers -+ ) -+ -+ -+def resource_op_defaults_set_remove_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ """ -+ return _defaults_set_remove_cmd( -+ lib.cib_options.operation_defaults_remove, argv, modifiers -+ ) -+ -+ -+def _defaults_set_update_cmd( -+ lib_command: Callable[..., Any], -+ argv: Sequence[str], -+ modifiers: InputModifiers, -+) -> None: - """ - Options: - * -f - CIB file - """ - modifiers.ensure_only_supported("-f") - if not argv: -- print("\n".join(show_defaults(utils.get_cib_dom(), "op_defaults"))) -- else: -- lib.cib_options.set_operations_defaults(prepare_options(argv)) -+ raise CmdLineInputError() -+ -+ set_id = argv[0] -+ groups = group_by_keywords( -+ argv[1:], set(["meta"]), keyword_repeat_allowed=False, -+ ) -+ lib_command( -+ set_id, prepare_options(groups["meta"]), -+ ) -+ -+ -+def resource_defaults_set_update_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ """ -+ return _defaults_set_update_cmd( -+ lib.cib_options.resource_defaults_update, argv, modifiers -+ ) -+ -+ -+def resource_op_defaults_set_update_cmd( -+ lib: Any, argv: Sequence[str], modifiers: InputModifiers, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ """ -+ return _defaults_set_update_cmd( -+ lib.cib_options.operation_defaults_update, argv, modifiers -+ ) -+ -+ -+def resource_defaults_legacy_cmd( -+ lib: Any, -+ argv: Sequence[str], -+ modifiers: InputModifiers, -+ deprecated_syntax_used: bool = False, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ """ -+ del modifiers -+ if deprecated_syntax_used: -+ warn( -+ "This command is deprecated and will be removed. " -+ "Please use 'pcs resource defaults update' instead." -+ ) -+ return lib.cib_options.resource_defaults_update(None, prepare_options(argv)) -+ -+ -+def resource_op_defaults_legacy_cmd( -+ lib: Any, -+ argv: Sequence[str], -+ modifiers: InputModifiers, -+ deprecated_syntax_used: bool = False, -+) -> None: -+ """ -+ Options: -+ * -f - CIB file -+ """ -+ del modifiers -+ if deprecated_syntax_used: -+ warn( -+ "This command is deprecated and will be removed. " -+ "Please use 'pcs resource op defaults update' instead." -+ ) -+ return lib.cib_options.operation_defaults_update( -+ None, prepare_options(argv) -+ ) - - - def resource_op_add_cmd(lib, argv, modifiers): -@@ -741,9 +948,9 @@ def resource_update(lib, args, modifiers, deal_with_guest_change=True): - process_library_reports(report_list) - except lib_ra.ResourceAgentError as e: - severity = ( -- ReportItemSeverity.WARNING -+ reports.ReportItemSeverity.WARNING - if modifiers.get("--force") -- else ReportItemSeverity.ERROR -+ else reports.ReportItemSeverity.ERROR - ) - process_library_reports( - [lib_ra.resource_agent_error_to_report_item(e, severity)] -@@ -2543,30 +2750,6 @@ def resource_failcount_show(lib, resource, node, operation, interval, full): - return "\n".join(result_lines) - - --def show_defaults(cib_dom, def_type): -- """ -- Commandline options: no options -- """ -- defs = cib_dom.getElementsByTagName(def_type) -- if not defs: -- return ["No defaults set"] -- defs = defs[0] -- -- # TODO duplicite to _nvpairs_strings -- key_val = { -- nvpair.getAttribute("name"): nvpair.getAttribute("value") -- for nvpair in defs.getElementsByTagName("nvpair") -- } -- if not key_val: -- return ["No defaults set"] -- strings = [] -- for name, value in sorted(key_val.items()): -- if " " in value: -- value = f'"{value}"' -- strings.append(f"{name}={value}") -- return strings -- -- - def resource_node_lines(node): - """ - Commandline options: no options -@@ -2677,6 +2860,7 @@ def _nvpairs_strings(node, parent_tag, extra_vars_dict=None): - """ - Commandline options: no options - """ -+ # In the new architecture, this is implemented in pcs.cli.nvset. - key_val = { - nvpair.attrib["name"]: nvpair.attrib["value"] - for nvpair in node.findall(f"{parent_tag}/nvpair") -diff --git a/pcs/usage.py b/pcs/usage.py -index 2cab7a6c..8722bd7b 100644 ---- a/pcs/usage.py -+++ b/pcs/usage.py -@@ -442,10 +442,50 @@ Commands: - op remove <operation id> - Remove the specified operation id. - -- op defaults [options] -- Set default values for operations, if no options are passed, lists -- currently configured defaults. Defaults do not apply to resources which -- override them with their own defined operations. -+ op defaults [config] [--full] -+ List currently configured default values for operations. If --full is -+ specified, also list ids. -+ -+ op defaults <name>=<value>... -+ Set default values for operations. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. -+ -+ op defaults set create [<set options>] [meta [<name>=<value>]...] -+ [rule [<expression>]] -+ Create a new set of default values for resource operations. You may -+ specify a rule describing resources and / or operations to which the set -+ applies. -+ Set options are: id, score -+ Expression looks like one of the following: -+ op <operation name> [interval=<interval>] -+ resource [<standard>]:[<provider>]:[<type>] -+ <expression> and|or <expression> -+ ( <expression> ) -+ You may specify all or any of 'standard', 'provider' and 'type' in -+ a resource expression. For example: 'resource ocf::' matches all -+ resources of 'ocf' standard, while 'resource ::Dummy' matches all -+ resources of 'Dummy' type regardless of their standard and provider. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. -+ -+ op defaults set delete [<set id>]... -+ Delete specified options sets. -+ -+ op defaults set remove [<set id>]... -+ Delete specified options sets. -+ -+ op defaults set update <set id> [meta [<name>=<value>]...] -+ Add, remove or change values in specified set of default values for -+ resource operations. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. -+ -+ op defaults update <name>=<value>... -+ Set default values for operations. This is a simplified command useful -+ for cases when you only manage one set of default values. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. - - meta <resource id | group id | clone id> <meta options> - [--wait[=n]] -@@ -561,10 +601,48 @@ Commands: - --monitor is specified, disable all monitor operations of the - resources. - -- defaults [options] -- Set default values for resources, if no options are passed, lists -- currently configured defaults. Defaults do not apply to resources which -- override them with their own defined values. -+ defaults [config] [--full] -+ List currently configured default values for resources. If --full is -+ specified, also list ids. -+ -+ defaults <name>=<value>... -+ Set default values for resources. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. -+ -+ defaults set create [<set options>] [meta [<name>=<value>]...] -+ [rule [<expression>]] -+ Create a new set of default values for resources. You may specify a rule -+ describing resources to which the set applies. -+ Set options are: id, score -+ Expression looks like one of the following: -+ resource [<standard>]:[<provider>]:[<type>] -+ <expression> and|or <expression> -+ ( <expression> ) -+ You may specify all or any of 'standard', 'provider' and 'type' in -+ a resource expression. For example: 'resource ocf::' matches all -+ resources of 'ocf' standard, while 'resource ::Dummy' matches all -+ resources of 'Dummy' type regardless of their standard and provider. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. -+ -+ defaults set delete [<set id>]... -+ Delete specified options sets. -+ -+ defaults set remove [<set id>]... -+ Delete specified options sets. -+ -+ defaults set update <set id> [meta [<name>=<value>]...] -+ Add, remove or change values in specified set of default values for -+ resources. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. -+ -+ defaults update <name>=<value>... -+ Set default values for resources. This is a simplified command useful -+ for cases when you only manage one set of default values. -+ NOTE: Defaults do not apply to resources which override them with their -+ own defined values. - - cleanup [<resource id>] [node=<node>] [operation=<operation> - [interval=<interval>]] [--strict] -diff --git a/pcs_test/resources/cib-empty-3.1.xml b/pcs_test/resources/cib-empty-3.1.xml -index 75bbb26d..88f5c414 100644 ---- a/pcs_test/resources/cib-empty-3.1.xml -+++ b/pcs_test/resources/cib-empty-3.1.xml -@@ -1,4 +1,4 @@ --<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.1" crm_feature_set="3.0.9" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> -+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.1" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> - <configuration> - <crm_config/> - <nodes> -diff --git a/pcs_test/resources/cib-empty-3.2.xml b/pcs_test/resources/cib-empty-3.2.xml -index 0b0b04b8..7ffaccb1 100644 ---- a/pcs_test/resources/cib-empty-3.2.xml -+++ b/pcs_test/resources/cib-empty-3.2.xml -@@ -1,4 +1,4 @@ --<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.2" crm_feature_set="3.0.9" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> -+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.2" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> - <configuration> - <crm_config/> - <nodes> -diff --git a/pcs_test/resources/cib-empty-3.3.xml b/pcs_test/resources/cib-empty-3.3.xml -new file mode 100644 -index 00000000..3a44fe08 ---- /dev/null -+++ b/pcs_test/resources/cib-empty-3.3.xml -@@ -0,0 +1,10 @@ -+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.3" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> -+ <configuration> -+ <crm_config/> -+ <nodes> -+ </nodes> -+ <resources/> -+ <constraints/> -+ </configuration> -+ <status/> -+</cib> -diff --git a/pcs_test/resources/cib-empty-3.4.xml b/pcs_test/resources/cib-empty-3.4.xml -new file mode 100644 -index 00000000..dcd4ff44 ---- /dev/null -+++ b/pcs_test/resources/cib-empty-3.4.xml -@@ -0,0 +1,10 @@ -+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.4" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> -+ <configuration> -+ <crm_config/> -+ <nodes> -+ </nodes> -+ <resources/> -+ <constraints/> -+ </configuration> -+ <status/> -+</cib> -diff --git a/pcs_test/resources/cib-empty.xml b/pcs_test/resources/cib-empty.xml -index 75bbb26d..7ffaccb1 100644 ---- a/pcs_test/resources/cib-empty.xml -+++ b/pcs_test/resources/cib-empty.xml -@@ -1,4 +1,4 @@ --<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.1" crm_feature_set="3.0.9" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> -+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.2" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2"> - <configuration> - <crm_config/> - <nodes> -diff --git a/pcs_test/tier0/cli/reports/test_messages.py b/pcs_test/tier0/cli/reports/test_messages.py -index 06f32e68..47aabd63 100644 ---- a/pcs_test/tier0/cli/reports/test_messages.py -+++ b/pcs_test/tier0/cli/reports/test_messages.py -@@ -481,6 +481,35 @@ class TagCannotRemoveReferencesWithoutRemovingTag(CliReportMessageTestBase): - ) - - -+class RuleExpressionParseError(CliReportMessageTestBase): -+ def test_success(self): -+ self.assert_message( -+ messages.RuleExpressionParseError( -+ "resource dummy op monitor", -+ "Expected end of text", -+ "resource dummy op monitor", -+ 1, -+ 16, -+ 15, -+ ), -+ "'resource dummy op monitor' is not a valid rule expression, " -+ "parse error near or after line 1 column 16\n" -+ " resource dummy op monitor\n" -+ " ---------------^", -+ ) -+ -+ -+class CibNvsetAmbiguousProvideNvsetId(CliReportMessageTestBase): -+ def test_success(self): -+ self.assert_message( -+ messages.CibNvsetAmbiguousProvideNvsetId( -+ const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE -+ ), -+ "Several options sets exist, please use the 'pcs resource defaults " -+ "set update' command and specify an option set ID", -+ ) -+ -+ - # TODO: create test/check that all subclasses of - # pcs.cli.reports.messages.CliReportMessageCustom have their test class with - # the same name in this file -diff --git a/pcs_test/tier0/cli/resource/test_defaults.py b/pcs_test/tier0/cli/resource/test_defaults.py -new file mode 100644 -index 00000000..0582c664 ---- /dev/null -+++ b/pcs_test/tier0/cli/resource/test_defaults.py -@@ -0,0 +1,324 @@ -+from textwrap import dedent -+from unittest import mock, TestCase -+ -+from pcs_test.tools.misc import dict_to_modifiers -+ -+from pcs import resource -+from pcs.cli.common.errors import CmdLineInputError -+from pcs.common.pacemaker.nvset import ( -+ CibNvpairDto, -+ CibNvsetDto, -+) -+from pcs.common.pacemaker.rule import CibRuleExpressionDto -+from pcs.common.reports import codes as report_codes -+from pcs.common.types import ( -+ CibNvsetType, -+ CibRuleExpressionType, -+) -+ -+ -+class DefaultsBaseMixin: -+ cli_command_name = "" -+ lib_command_name = "" -+ -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.lib = mock.Mock(spec_set=["cib_options"]) -+ self.cib_options = mock.Mock(spec_set=[self.lib_command_name]) -+ self.lib.cib_options = self.cib_options -+ self.lib_command = getattr(self.cib_options, self.lib_command_name) -+ self.cli_command = getattr(resource, self.cli_command_name) -+ -+ def _call_cmd(self, argv, modifiers=None): -+ modifiers = modifiers or dict() -+ self.cli_command(self.lib, argv, dict_to_modifiers(modifiers)) -+ -+ -+@mock.patch("pcs.resource.print") -+class DefaultsConfigMixin(DefaultsBaseMixin): -+ dto_list = [ -+ CibNvsetDto( -+ "my-meta_attributes", -+ CibNvsetType.META, -+ {}, -+ CibRuleExpressionDto( -+ "my-meta-rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "and", "score": "INFINITY"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-meta-rule-rsc", -+ CibRuleExpressionType.RSC_EXPRESSION, -+ False, -+ { -+ "class": "ocf", -+ "provider": "pacemaker", -+ "type": "Dummy", -+ }, -+ None, -+ None, -+ [], -+ "resource ocf:pacemaker:Dummy", -+ ), -+ ], -+ "resource ocf:pacemaker:Dummy", -+ ), -+ [ -+ CibNvpairDto("my-id-pair1", "name1", "value1"), -+ CibNvpairDto("my-id-pair2", "name2", "value2"), -+ ], -+ ), -+ CibNvsetDto( -+ "instance", -+ CibNvsetType.INSTANCE, -+ {}, -+ None, -+ [CibNvpairDto("instance-pair", "inst", "ance")], -+ ), -+ CibNvsetDto( -+ "meta-plain", -+ CibNvsetType.META, -+ {"score": "123"}, -+ None, -+ [CibNvpairDto("my-id-pair3", "name 1", "value 1")], -+ ), -+ ] -+ -+ def test_no_args(self, mock_print): -+ self.lib_command.return_value = [] -+ self._call_cmd([]) -+ self.lib_command.assert_called_once_with() -+ mock_print.assert_called_once_with("No defaults set") -+ -+ def test_usage(self, mock_print): -+ with self.assertRaises(CmdLineInputError) as cm: -+ self._call_cmd(["arg"]) -+ self.assertIsNone(cm.exception.message) -+ self.lib_command.assert_not_called() -+ mock_print.assert_not_called() -+ -+ def test_full(self, mock_print): -+ self.lib_command.return_value = [] -+ self._call_cmd([], {"full": True}) -+ self.lib_command.assert_called_once_with() -+ mock_print.assert_called_once_with("No defaults set") -+ -+ def test_print(self, mock_print): -+ self.lib_command.return_value = self.dto_list -+ self._call_cmd([]) -+ self.lib_command.assert_called_once_with() -+ mock_print.assert_called_once_with( -+ dedent( -+ '''\ -+ Meta Attrs: my-meta_attributes -+ name1=value1 -+ name2=value2 -+ Rule: boolean-op=and score=INFINITY -+ Expression: resource ocf:pacemaker:Dummy -+ Attributes: instance -+ inst=ance -+ Meta Attrs: meta-plain score=123 -+ "name 1"="value 1"''' -+ ) -+ ) -+ -+ def test_print_full(self, mock_print): -+ self.lib_command.return_value = self.dto_list -+ self._call_cmd([], {"full": True}) -+ self.lib_command.assert_called_once_with() -+ mock_print.assert_called_once_with( -+ dedent( -+ '''\ -+ Meta Attrs: my-meta_attributes -+ name1=value1 -+ name2=value2 -+ Rule: boolean-op=and score=INFINITY (id:my-meta-rule) -+ Expression: resource ocf:pacemaker:Dummy (id:my-meta-rule-rsc) -+ Attributes: instance -+ inst=ance -+ Meta Attrs: meta-plain score=123 -+ "name 1"="value 1"''' -+ ) -+ ) -+ -+ -+class RscDefaultsConfig(DefaultsConfigMixin, TestCase): -+ cli_command_name = "resource_defaults_config_cmd" -+ lib_command_name = "resource_defaults_config" -+ -+ -+class OpDefaultsConfig(DefaultsConfigMixin, TestCase): -+ cli_command_name = "resource_op_defaults_config_cmd" -+ lib_command_name = "operation_defaults_config" -+ -+ -+class DefaultsSetCreateMixin(DefaultsBaseMixin): -+ def test_no_args(self): -+ self._call_cmd([]) -+ self.lib_command.assert_called_once_with( -+ {}, {}, nvset_rule=None, force_flags=set() -+ ) -+ -+ def test_no_values(self): -+ self._call_cmd(["meta", "rule"]) -+ self.lib_command.assert_called_once_with( -+ {}, {}, nvset_rule=None, force_flags=set() -+ ) -+ -+ def test_bad_options_or_keyword(self): -+ with self.assertRaises(CmdLineInputError) as cm: -+ self._call_cmd(["aaa"]) -+ self.assertEqual( -+ cm.exception.message, "missing value of 'aaa' option", -+ ) -+ self.lib_command.assert_not_called() -+ -+ def test_bad_values(self): -+ with self.assertRaises(CmdLineInputError) as cm: -+ self._call_cmd(["meta", "aaa"]) -+ self.assertEqual( -+ cm.exception.message, "missing value of 'aaa' option", -+ ) -+ self.lib_command.assert_not_called() -+ -+ def test_options(self): -+ self._call_cmd(["id=custom-id", "score=10"]) -+ self.lib_command.assert_called_once_with( -+ {}, -+ {"id": "custom-id", "score": "10"}, -+ nvset_rule=None, -+ force_flags=set(), -+ ) -+ -+ def test_nvpairs(self): -+ self._call_cmd(["meta", "name1=value1", "name2=value2"]) -+ self.lib_command.assert_called_once_with( -+ {"name1": "value1", "name2": "value2"}, -+ {}, -+ nvset_rule=None, -+ force_flags=set(), -+ ) -+ -+ def test_rule(self): -+ self._call_cmd(["rule", "resource", "dummy", "or", "op", "monitor"]) -+ self.lib_command.assert_called_once_with( -+ {}, -+ {}, -+ nvset_rule="resource dummy or op monitor", -+ force_flags=set(), -+ ) -+ -+ def test_force(self): -+ self._call_cmd([], {"force": True}) -+ self.lib_command.assert_called_once_with( -+ {}, {}, nvset_rule=None, force_flags=set([report_codes.FORCE]) -+ ) -+ -+ def test_all(self): -+ self._call_cmd( -+ [ -+ "id=custom-id", -+ "score=10", -+ "meta", -+ "name1=value1", -+ "name2=value2", -+ "rule", -+ "resource", -+ "dummy", -+ "or", -+ "op", -+ "monitor", -+ ], -+ {"force": True}, -+ ) -+ self.lib_command.assert_called_once_with( -+ {"name1": "value1", "name2": "value2"}, -+ {"id": "custom-id", "score": "10"}, -+ nvset_rule="resource dummy or op monitor", -+ force_flags=set([report_codes.FORCE]), -+ ) -+ -+ -+class RscDefaultsSetCreate(DefaultsSetCreateMixin, TestCase): -+ cli_command_name = "resource_defaults_set_create_cmd" -+ lib_command_name = "resource_defaults_create" -+ -+ -+class OpDefaultsSetCreate(DefaultsSetCreateMixin, TestCase): -+ cli_command_name = "resource_op_defaults_set_create_cmd" -+ lib_command_name = "operation_defaults_create" -+ -+ -+class DefaultsSetRemoveMixin(DefaultsBaseMixin): -+ def test_no_args(self): -+ self._call_cmd([]) -+ self.lib_command.assert_called_once_with([]) -+ -+ def test_some_args(self): -+ self._call_cmd(["set1", "set2"]) -+ self.lib_command.assert_called_once_with(["set1", "set2"]) -+ -+ -+class RscDefaultsSetRemove(DefaultsSetRemoveMixin, TestCase): -+ cli_command_name = "resource_defaults_set_remove_cmd" -+ lib_command_name = "resource_defaults_remove" -+ -+ -+class OpDefaultsSetRemove(DefaultsSetRemoveMixin, TestCase): -+ cli_command_name = "resource_op_defaults_set_remove_cmd" -+ lib_command_name = "operation_defaults_remove" -+ -+ -+class DefaultsSetUpdateMixin(DefaultsBaseMixin): -+ def test_no_args(self): -+ with self.assertRaises(CmdLineInputError) as cm: -+ self._call_cmd([]) -+ self.assertIsNone(cm.exception.message) -+ self.lib_command.assert_not_called() -+ -+ def test_no_meta(self): -+ self._call_cmd(["nvset-id"]) -+ self.lib_command.assert_called_once_with("nvset-id", {}) -+ -+ def test_no_meta_values(self): -+ self._call_cmd(["nvset-id", "meta"]) -+ self.lib_command.assert_called_once_with("nvset-id", {}) -+ -+ def test_meta_values(self): -+ self._call_cmd(["nvset-id", "meta", "a=b", "c=d"]) -+ self.lib_command.assert_called_once_with( -+ "nvset-id", {"a": "b", "c": "d"} -+ ) -+ -+ -+class RscDefaultsSetUpdate(DefaultsSetUpdateMixin, TestCase): -+ cli_command_name = "resource_defaults_set_update_cmd" -+ lib_command_name = "resource_defaults_update" -+ -+ -+class OpDefaultsSetUpdate(DefaultsSetUpdateMixin, TestCase): -+ cli_command_name = "resource_op_defaults_set_update_cmd" -+ lib_command_name = "operation_defaults_update" -+ -+ -+class DefaultsUpdateMixin(DefaultsBaseMixin): -+ def test_no_args(self): -+ self._call_cmd([]) -+ self.lib_command.assert_called_once_with(None, {}) -+ -+ def test_args(self): -+ self._call_cmd(["a=b", "c="]) -+ self.lib_command.assert_called_once_with(None, {"a": "b", "c": ""}) -+ -+ -+class RscDefaultsUpdate(DefaultsUpdateMixin, TestCase): -+ cli_command_name = "resource_defaults_legacy_cmd" -+ lib_command_name = "resource_defaults_update" -+ -+ -+class OpDefaultsUpdate(DefaultsUpdateMixin, TestCase): -+ cli_command_name = "resource_op_defaults_legacy_cmd" -+ lib_command_name = "operation_defaults_update" -diff --git a/pcs_test/tier0/cli/test_nvset.py b/pcs_test/tier0/cli/test_nvset.py -new file mode 100644 -index 00000000..675d2899 ---- /dev/null -+++ b/pcs_test/tier0/cli/test_nvset.py -@@ -0,0 +1,92 @@ -+import re -+from textwrap import dedent -+from unittest import TestCase -+ -+from pcs.cli import nvset -+from pcs.common.pacemaker.nvset import ( -+ CibNvpairDto, -+ CibNvsetDto, -+) -+from pcs.common.pacemaker.rule import CibRuleExpressionDto -+from pcs.common.types import ( -+ CibNvsetType, -+ CibRuleExpressionType, -+) -+ -+ -+class NvsetDtoToLines(TestCase): -+ type_to_label = ( -+ (CibNvsetType.META, "Meta Attrs"), -+ (CibNvsetType.INSTANCE, "Attributes"), -+ ) -+ -+ @staticmethod -+ def _export(dto, with_ids): -+ return ( -+ "\n".join(nvset.nvset_dto_to_lines(dto, with_ids=with_ids)) + "\n" -+ ) -+ -+ def assert_lines(self, dto, lines): -+ self.assertEqual( -+ self._export(dto, True), lines, -+ ) -+ self.assertEqual( -+ self._export(dto, False), re.sub(r" +\(id:.*\)", "", lines), -+ ) -+ -+ def test_minimal(self): -+ for nvtype, label in self.type_to_label: -+ with self.subTest(nvset_type=nvtype, lanel=label): -+ dto = CibNvsetDto("my-id", nvtype, {}, None, []) -+ output = dedent( -+ f"""\ -+ {label}: my-id -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_full(self): -+ for nvtype, label in self.type_to_label: -+ with self.subTest(nvset_type=nvtype, lanel=label): -+ dto = CibNvsetDto( -+ "my-id", -+ nvtype, -+ {"score": "150"}, -+ CibRuleExpressionDto( -+ "my-id-rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "or"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-rule-op", -+ CibRuleExpressionType.OP_EXPRESSION, -+ False, -+ {"name": "monitor"}, -+ None, -+ None, -+ [], -+ "op monitor", -+ ), -+ ], -+ "op monitor", -+ ), -+ [ -+ CibNvpairDto("my-id-pair1", "name1", "value1"), -+ CibNvpairDto("my-id-pair2", "name 2", "value 2"), -+ CibNvpairDto("my-id-pair3", "name=3", "value=3"), -+ ], -+ ) -+ output = dedent( -+ f"""\ -+ {label}: my-id score=150 -+ "name 2"="value 2" -+ name1=value1 -+ "name=3"="value=3" -+ Rule: boolean-op=or (id:my-id-rule) -+ Expression: op monitor (id:my-id-rule-op) -+ """ -+ ) -+ self.assert_lines(dto, output) -diff --git a/pcs_test/tier0/cli/test_rule.py b/pcs_test/tier0/cli/test_rule.py -new file mode 100644 -index 00000000..c3f6ddc4 ---- /dev/null -+++ b/pcs_test/tier0/cli/test_rule.py -@@ -0,0 +1,477 @@ -+import re -+from textwrap import dedent -+from unittest import TestCase -+ -+from pcs.cli import rule -+from pcs.common.pacemaker.rule import ( -+ CibRuleDateCommonDto, -+ CibRuleExpressionDto, -+) -+from pcs.common.types import CibRuleExpressionType -+ -+ -+class RuleDtoToLinesMixin: -+ @staticmethod -+ def _export(dto, with_ids): -+ return ( -+ "\n".join(rule.rule_expression_dto_to_lines(dto, with_ids=with_ids)) -+ + "\n" -+ ) -+ -+ def assert_lines(self, dto, lines): -+ self.assertEqual( -+ self._export(dto, True), lines, -+ ) -+ self.assertEqual( -+ self._export(dto, False), re.sub(r" +\(id:.*\)", "", lines), -+ ) -+ -+ -+class ExpressionDtoToLines(RuleDtoToLinesMixin, TestCase): -+ def test_defined(self): -+ dto = CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ {"attribute": "pingd", "operation": "defined"}, -+ None, -+ None, -+ [], -+ "defined pingd", -+ ), -+ ], -+ "defined pingd", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:my-id) -+ Expression: defined pingd (id:my-id-expr) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_value_comparison(self): -+ dto = CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "my-attr", -+ "operation": "eq", -+ "value": "my value", -+ }, -+ None, -+ None, -+ [], -+ "my-attr eq 'my value'", -+ ), -+ ], -+ "my-attr eq 'my value'", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:my-id) -+ Expression: my-attr eq 'my value' (id:my-id-expr) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_value_comparison_with_type(self): -+ dto = CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "foo", -+ "operation": "gt", -+ "type": "version", -+ "value": "1.2.3", -+ }, -+ None, -+ None, -+ [], -+ "foo gt version 1.2.3", -+ ), -+ ], -+ "foo gt version 1.2.3", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:my-id) -+ Expression: foo gt version 1.2.3 (id:my-id-expr) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ -+class DateExpressionDtoToLines(RuleDtoToLinesMixin, TestCase): -+ def test_simple(self): -+ dto = CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "gt", "start": "2014-06-26"}, -+ None, -+ None, -+ [], -+ "date gt 2014-06-26", -+ ), -+ ], -+ "date gt 2014-06-26", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:rule) -+ Expression: date gt 2014-06-26 (id:rule-expr) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_datespec(self): -+ dto = CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "date_spec"}, -+ CibRuleDateCommonDto( -+ "rule-expr-datespec", -+ {"hours": "1-14", "monthdays": "20-30", "months": "1"}, -+ ), -+ None, -+ [], -+ "date-spec hours=1-14 monthdays=20-30 months=1", -+ ), -+ ], -+ "date-spec hours=1-14 monthdays=20-30 months=1", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:rule) -+ Expression: (id:rule-expr) -+ Date Spec: hours=1-14 monthdays=20-30 months=1 (id:rule-expr-datespec) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_inrange(self): -+ dto = CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ { -+ "operation": "in_range", -+ "start": "2014-06-26", -+ "end": "2014-07-26", -+ }, -+ None, -+ None, -+ [], -+ "date in_range 2014-06-26 to 2014-07-26", -+ ), -+ ], -+ "date in_range 2014-06-26 to 2014-07-26", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:rule) -+ Expression: date in_range 2014-06-26 to 2014-07-26 (id:rule-expr) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_inrange_duration(self): -+ dto = CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "in_range", "start": "2014-06-26",}, -+ None, -+ CibRuleDateCommonDto("rule-expr-duration", {"years": "1"}), -+ [], -+ "date in_range 2014-06-26 to duration years=1", -+ ), -+ ], -+ "date in_range 2014-06-26 to duration years=1", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:rule) -+ Expression: date in_range 2014-06-26 to duration (id:rule-expr) -+ Duration: years=1 (id:rule-expr-duration) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ -+class OpExpressionDtoToLines(RuleDtoToLinesMixin, TestCase): -+ def test_minimal(self): -+ dto = CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-op", -+ CibRuleExpressionType.OP_EXPRESSION, -+ False, -+ {"name": "start"}, -+ None, -+ None, -+ [], -+ "op start", -+ ), -+ ], -+ "op start", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:my-id) -+ Expression: op start (id:my-id-op) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ def test_interval(self): -+ dto = CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-op", -+ CibRuleExpressionType.OP_EXPRESSION, -+ False, -+ {"name": "start", "interval": "2min"}, -+ None, -+ None, -+ [], -+ "op start interval=2min", -+ ), -+ ], -+ "op start interval=2min", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:my-id) -+ Expression: op start interval=2min (id:my-id-op) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ -+class ResourceExpressionDtoToLines(RuleDtoToLinesMixin, TestCase): -+ def test_success(self): -+ dto = CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.RSC_EXPRESSION, -+ False, -+ {"class": "ocf", "provider": "pacemaker", "type": "Dummy"}, -+ None, -+ None, -+ [], -+ "resource ocf:pacemaker:Dummy", -+ ), -+ ], -+ "resource ocf:pacemaker:Dummy", -+ ) -+ output = dedent( -+ """\ -+ Rule: (id:my-id) -+ Expression: resource ocf:pacemaker:Dummy (id:my-id-expr) -+ """ -+ ) -+ self.assert_lines(dto, output) -+ -+ -+class RuleDtoToLines(RuleDtoToLinesMixin, TestCase): -+ def test_complex_rule(self): -+ dto = CibRuleExpressionDto( -+ "complex", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "or", "score": "INFINITY"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "complex-rule-1", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "and", "score": "0"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "complex-rule-1-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "date_spec"}, -+ CibRuleDateCommonDto( -+ "complex-rule-1-expr-datespec", -+ {"hours": "12-23", "weekdays": "1-5"}, -+ ), -+ None, -+ [], -+ "date-spec hours=12-23 weekdays=1-5", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule-1-expr-1", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "in_range", "start": "2014-07-26",}, -+ None, -+ CibRuleDateCommonDto( -+ "complex-rule-1-expr-1-durat", {"months": "1"}, -+ ), -+ [], -+ "date in_range 2014-07-26 to duration months=1", -+ ), -+ ], -+ "date-spec hours=12-23 weekdays=1-5 and date in_range " -+ "2014-07-26 to duration months=1", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "and", "score": "0"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "complex-rule-expr-1", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "foo", -+ "operation": "gt", -+ "type": "version", -+ "value": "1.2", -+ }, -+ None, -+ None, -+ [], -+ "foo gt version 1.2", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "#uname", -+ "operation": "eq", -+ "value": "node3 4", -+ }, -+ None, -+ None, -+ [], -+ "#uname eq 'node3 4'", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule-expr-2", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "#uname", -+ "operation": "eq", -+ "value": "nodeA", -+ }, -+ None, -+ None, -+ [], -+ "#uname eq nodeA", -+ ), -+ ], -+ "foo gt version 1.2 and #uname eq 'node3 4' and #uname " -+ "eq nodeA", -+ ), -+ ], -+ "(date-spec hours=12-23 weekdays=1-5 and date in_range " -+ "2014-07-26 to duration months=1) or (foo gt version 1.2 and " -+ "#uname eq 'node3 4' and #uname eq nodeA)", -+ ) -+ output = dedent( -+ """\ -+ Rule: boolean-op=or score=INFINITY (id:complex) -+ Rule: boolean-op=and score=0 (id:complex-rule-1) -+ Expression: (id:complex-rule-1-expr) -+ Date Spec: hours=12-23 weekdays=1-5 (id:complex-rule-1-expr-datespec) -+ Expression: date in_range 2014-07-26 to duration (id:complex-rule-1-expr-1) -+ Duration: months=1 (id:complex-rule-1-expr-1-durat) -+ Rule: boolean-op=and score=0 (id:complex-rule) -+ Expression: foo gt version 1.2 (id:complex-rule-expr-1) -+ Expression: #uname eq 'node3 4' (id:complex-rule-expr) -+ Expression: #uname eq nodeA (id:complex-rule-expr-2) -+ """ -+ ) -+ self.assert_lines(dto, output) -diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py -index 2592bd40..fd217ffb 100644 ---- a/pcs_test/tier0/common/reports/test_messages.py -+++ b/pcs_test/tier0/common/reports/test_messages.py -@@ -1,16 +1,17 @@ - from unittest import TestCase - - from pcs.common import file_type_codes --from pcs.common.file import RawFileError --from pcs.common.reports import ( -- const, -- messages as reports, --) - from pcs.common.fencing_topology import ( - TARGET_TYPE_NODE, - TARGET_TYPE_REGEXP, - TARGET_TYPE_ATTRIBUTE, - ) -+from pcs.common.file import RawFileError -+from pcs.common.reports import ( -+ const, -+ messages as reports, -+) -+from pcs.common.types import CibRuleExpressionType - - # pylint: disable=too-many-lines - -@@ -4653,3 +4654,47 @@ class TagIdsNotInTheTag(NameBuildTest): - "Tag 'tag-id' does not contain ids: 'a', 'b'", - reports.TagIdsNotInTheTag("tag-id", ["b", "a"]), - ) -+ -+ -+class RuleExpressionParseError(NameBuildTest): -+ def test_success(self): -+ self.assert_message_from_report( -+ "'resource dummy op monitor' is not a valid rule expression, " -+ "parse error near or after line 1 column 16", -+ reports.RuleExpressionParseError( -+ "resource dummy op monitor", -+ "Expected end of text", -+ "resource dummy op monitor", -+ 1, -+ 16, -+ 15, -+ ), -+ ) -+ -+ -+class RuleExpressionNotAllowed(NameBuildTest): -+ def test_op(self): -+ self.assert_message_from_report( -+ "Keyword 'op' cannot be used in a rule in this command", -+ reports.RuleExpressionNotAllowed( -+ CibRuleExpressionType.OP_EXPRESSION -+ ), -+ ) -+ -+ def test_rsc(self): -+ self.assert_message_from_report( -+ "Keyword 'resource' cannot be used in a rule in this command", -+ reports.RuleExpressionNotAllowed( -+ CibRuleExpressionType.RSC_EXPRESSION -+ ), -+ ) -+ -+ -+class CibNvsetAmbiguousProvideNvsetId(NameBuildTest): -+ def test_success(self): -+ self.assert_message_from_report( -+ "Several options sets exist, please specify an option set ID", -+ reports.CibNvsetAmbiguousProvideNvsetId( -+ const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE -+ ), -+ ) -diff --git a/pcs_test/tier0/common/test_str_tools.py b/pcs_test/tier0/common/test_str_tools.py -index c4753437..97c1d223 100644 ---- a/pcs_test/tier0/common/test_str_tools.py -+++ b/pcs_test/tier0/common/test_str_tools.py -@@ -249,6 +249,39 @@ class FormatListCustomLastSeparatort(TestCase): - ) - - -+class FormatNameValueList(TestCase): -+ def test_empty(self): -+ self.assertEqual([], tools.format_name_value_list([])) -+ -+ def test_many(self): -+ self.assertEqual( -+ ["name1=value1", '"name=2"="value 2"', '"name 3"="value=3"'], -+ tools.format_name_value_list( -+ [ -+ ("name1", "value1"), -+ ("name=2", "value 2"), -+ ("name 3", "value=3"), -+ ] -+ ), -+ ) -+ -+ -+class Quote(TestCase): -+ def test_no_quote(self): -+ self.assertEqual("string", tools.quote("string", " ")) -+ self.assertEqual("string", tools.quote("string", " =")) -+ -+ def test_quote(self): -+ self.assertEqual('"str ing"', tools.quote("str ing", " =")) -+ self.assertEqual('"str=ing"', tools.quote("str=ing", " =")) -+ -+ def test_alternative_quote(self): -+ self.assertEqual("""'st"r i"ng'""", tools.quote('st"r i"ng', " ")) -+ -+ def test_escape(self): -+ self.assertEqual('''"st\\"r i'ng"''', tools.quote("st\"r i'ng", " ")) -+ -+ - class Transform(TestCase): - def test_transform(self): - self.assertEqual( -diff --git a/pcs_test/tier0/lib/commands/cib_options/__init__.py b/pcs_test/tier0/lib/cib/rule/__init__.py -similarity index 100% -rename from pcs_test/tier0/lib/commands/cib_options/__init__.py -rename to pcs_test/tier0/lib/cib/rule/__init__.py -diff --git a/pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py b/pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py -new file mode 100644 -index 00000000..ce06c469 ---- /dev/null -+++ b/pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py -@@ -0,0 +1,593 @@ -+from unittest import TestCase -+ -+from lxml import etree -+ -+from pcs.common.pacemaker.rule import ( -+ CibRuleDateCommonDto, -+ CibRuleExpressionDto, -+) -+from pcs.common.types import CibRuleExpressionType -+from pcs.lib.cib.rule import rule_element_to_dto -+ -+ -+class ExpressionToDto(TestCase): -+ def test_defined(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="my-id"> -+ <expression id="my-id-expr" -+ attribute="pingd" operation="defined" -+ /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ {"attribute": "pingd", "operation": "defined"}, -+ None, -+ None, -+ [], -+ "defined pingd", -+ ), -+ ], -+ "defined pingd", -+ ), -+ ) -+ -+ def test_value_comparison(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="my-id"> -+ <expression id="my-id-expr" -+ attribute="my-attr" operation="eq" value="my value" -+ /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "my-attr", -+ "operation": "eq", -+ "value": "my value", -+ }, -+ None, -+ None, -+ [], -+ 'my-attr eq "my value"', -+ ), -+ ], -+ 'my-attr eq "my value"', -+ ), -+ ) -+ -+ def test_value_comparison_with_type(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="my-id"> -+ <expression id="my-id-expr" -+ attribute="foo" operation="gt" type="version" value="1.2.3" -+ /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "foo", -+ "operation": "gt", -+ "type": "version", -+ "value": "1.2.3", -+ }, -+ None, -+ None, -+ [], -+ "foo gt version 1.2.3", -+ ), -+ ], -+ "foo gt version 1.2.3", -+ ), -+ ) -+ -+ -+class DateExpressionToDto(TestCase): -+ def test_gt(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="rule"> -+ <date_expression id="rule-expr" -+ operation="gt" start="2014-06-26" -+ /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "gt", "start": "2014-06-26"}, -+ None, -+ None, -+ [], -+ "date gt 2014-06-26", -+ ), -+ ], -+ "date gt 2014-06-26", -+ ), -+ ) -+ -+ def test_lt(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="rule"> -+ <date_expression id="rule-expr" -+ operation="lt" end="2014-06-26" -+ /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "lt", "end": "2014-06-26"}, -+ None, -+ None, -+ [], -+ "date lt 2014-06-26", -+ ), -+ ], -+ "date lt 2014-06-26", -+ ), -+ ) -+ -+ def test_datespec(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="rule"> -+ <date_expression id="rule-expr" operation="date_spec"> -+ <date_spec id="rule-expr-datespec" -+ hours="1-14" monthdays="20-30" months="1" -+ /> -+ </date_expression> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "date_spec"}, -+ CibRuleDateCommonDto( -+ "rule-expr-datespec", -+ { -+ "hours": "1-14", -+ "monthdays": "20-30", -+ "months": "1", -+ }, -+ ), -+ None, -+ [], -+ "date-spec hours=1-14 monthdays=20-30 months=1", -+ ), -+ ], -+ "date-spec hours=1-14 monthdays=20-30 months=1", -+ ), -+ ) -+ -+ def test_inrange(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="rule"> -+ <date_expression id="rule-expr" -+ operation="in_range" start="2014-06-26" end="2014-07-26" -+ /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ { -+ "operation": "in_range", -+ "start": "2014-06-26", -+ "end": "2014-07-26", -+ }, -+ None, -+ None, -+ [], -+ "date in_range 2014-06-26 to 2014-07-26", -+ ), -+ ], -+ "date in_range 2014-06-26 to 2014-07-26", -+ ), -+ ) -+ -+ def test_inrange_duration(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="rule"> -+ <date_expression id="rule-expr" -+ operation="in_range" start="2014-06-26" -+ > -+ <duration id="rule-expr-duration" years="1"/> -+ </date_expression> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "rule-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "in_range", "start": "2014-06-26",}, -+ None, -+ CibRuleDateCommonDto( -+ "rule-expr-duration", {"years": "1"}, -+ ), -+ [], -+ "date in_range 2014-06-26 to duration years=1", -+ ), -+ ], -+ "date in_range 2014-06-26 to duration years=1", -+ ), -+ ) -+ -+ -+class OpExpressionToDto(TestCase): -+ def test_minimal(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="my-id"> -+ <op_expression id="my-id-op" name="start" /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-op", -+ CibRuleExpressionType.OP_EXPRESSION, -+ False, -+ {"name": "start"}, -+ None, -+ None, -+ [], -+ "op start", -+ ), -+ ], -+ "op start", -+ ), -+ ) -+ -+ def test_interval(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="my-id"> -+ <op_expression id="my-id-op" name="start" interval="2min" /> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-op", -+ CibRuleExpressionType.OP_EXPRESSION, -+ False, -+ {"name": "start", "interval": "2min"}, -+ None, -+ None, -+ [], -+ "op start interval=2min", -+ ), -+ ], -+ "op start interval=2min", -+ ), -+ ) -+ -+ -+class ResourceExpressionToDto(TestCase): -+ def test_success(self): -+ test_data = [ -+ # ((class, provider, type), output) -+ ((None, None, None), "::"), -+ (("ocf", None, None), "ocf::"), -+ ((None, "pacemaker", None), ":pacemaker:"), -+ ((None, None, "Dummy"), "::Dummy"), -+ (("ocf", "pacemaker", None), "ocf:pacemaker:"), -+ (("ocf", None, "Dummy"), "ocf::Dummy"), -+ ((None, "pacemaker", "Dummy"), ":pacemaker:Dummy"), -+ (("ocf", "pacemaker", "Dummy"), "ocf:pacemaker:Dummy"), -+ ] -+ for in_data, out_data in test_data: -+ with self.subTest(in_data=in_data): -+ attrs = {} -+ if in_data[0] is not None: -+ attrs["class"] = in_data[0] -+ if in_data[1] is not None: -+ attrs["provider"] = in_data[1] -+ if in_data[2] is not None: -+ attrs["type"] = in_data[2] -+ attrs_str = " ".join( -+ [f"{name}='{value}'" for name, value in attrs.items()] -+ ) -+ xml = etree.fromstring( -+ f""" -+ <rule id="my-id"> -+ <rsc_expression id="my-id-expr" {attrs_str}/> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "my-id", -+ CibRuleExpressionType.RULE, -+ False, -+ {}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-expr", -+ CibRuleExpressionType.RSC_EXPRESSION, -+ False, -+ attrs, -+ None, -+ None, -+ [], -+ f"resource {out_data}", -+ ), -+ ], -+ f"resource {out_data}", -+ ), -+ ) -+ -+ -+class RuleToDto(TestCase): -+ def test_complex_rule(self): -+ xml = etree.fromstring( -+ """ -+ <rule id="complex" boolean-op="or" score="INFINITY"> -+ <rule id="complex-rule-1" boolean-op="and" score="0"> -+ <date_expression id="complex-rule-1-expr" -+ operation="date_spec" -+ > -+ <date_spec id="complex-rule-1-expr-datespec" -+ weekdays="1-5" hours="12-23" -+ /> -+ </date_expression> -+ <date_expression id="complex-rule-1-expr-1" -+ operation="in_range" start="2014-07-26" -+ > -+ <duration id="complex-rule-1-expr-1-durat" months="1"/> -+ </date_expression> -+ </rule> -+ <rule id="complex-rule" boolean-op="and" score="0"> -+ <expression id="complex-rule-expr-1" -+ attribute="foo" operation="gt" type="version" value="1.2" -+ /> -+ <expression id="complex-rule-expr" -+ attribute="#uname" operation="eq" value="node3 4" -+ /> -+ <expression id="complex-rule-expr-2" -+ attribute="#uname" operation="eq" value="nodeA" -+ /> -+ </rule> -+ </rule> -+ """ -+ ) -+ self.assertEqual( -+ rule_element_to_dto(xml), -+ CibRuleExpressionDto( -+ "complex", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "or", "score": "INFINITY"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "complex-rule-1", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "and", "score": "0"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "complex-rule-1-expr", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ {"operation": "date_spec"}, -+ CibRuleDateCommonDto( -+ "complex-rule-1-expr-datespec", -+ {"hours": "12-23", "weekdays": "1-5"}, -+ ), -+ None, -+ [], -+ "date-spec hours=12-23 weekdays=1-5", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule-1-expr-1", -+ CibRuleExpressionType.DATE_EXPRESSION, -+ False, -+ { -+ "operation": "in_range", -+ "start": "2014-07-26", -+ }, -+ None, -+ CibRuleDateCommonDto( -+ "complex-rule-1-expr-1-durat", -+ {"months": "1"}, -+ ), -+ [], -+ "date in_range 2014-07-26 to duration months=1", -+ ), -+ ], -+ "date-spec hours=12-23 weekdays=1-5 and date in_range " -+ "2014-07-26 to duration months=1", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "and", "score": "0"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "complex-rule-expr-1", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "foo", -+ "operation": "gt", -+ "type": "version", -+ "value": "1.2", -+ }, -+ None, -+ None, -+ [], -+ "foo gt version 1.2", -+ ), -+ CibRuleExpressionDto( -+ "complex-rule-expr", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "#uname", -+ "operation": "eq", -+ "value": "node3 4", -+ }, -+ None, -+ None, -+ [], -+ '#uname eq "node3 4"', -+ ), -+ CibRuleExpressionDto( -+ "complex-rule-expr-2", -+ CibRuleExpressionType.EXPRESSION, -+ False, -+ { -+ "attribute": "#uname", -+ "operation": "eq", -+ "value": "nodeA", -+ }, -+ None, -+ None, -+ [], -+ "#uname eq nodeA", -+ ), -+ ], -+ 'foo gt version 1.2 and #uname eq "node3 4" and #uname ' -+ "eq nodeA", -+ ), -+ ], -+ "(date-spec hours=12-23 weekdays=1-5 and date in_range " -+ "2014-07-26 to duration months=1) or (foo gt version 1.2 and " -+ '#uname eq "node3 4" and #uname eq nodeA)', -+ ), -+ ) -diff --git a/pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py b/pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py -new file mode 100644 -index 00000000..f61fce99 ---- /dev/null -+++ b/pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py -@@ -0,0 +1,214 @@ -+from unittest import TestCase -+ -+from lxml import etree -+ -+from pcs_test.tools.assertions import assert_xml_equal -+from pcs_test.tools.xml import etree_to_str -+ -+from pcs.lib.cib import rule -+from pcs.lib.cib.rule.expression_part import ( -+ BOOL_AND, -+ BOOL_OR, -+ BoolExpr, -+ OpExpr, -+ RscExpr, -+) -+from pcs.lib.cib.tools import IdProvider -+ -+ -+class Base(TestCase): -+ @staticmethod -+ def assert_cib(tree, expected_xml): -+ xml = etree.fromstring('<root id="X"/>') -+ rule.rule_to_cib(xml, IdProvider(xml), tree) -+ assert_xml_equal( -+ '<root id="X">' + expected_xml + "</root>", etree_to_str(xml) -+ ) -+ -+ -+class SimpleBool(Base): -+ def test_no_children(self): -+ self.assert_cib( -+ BoolExpr(BOOL_AND, []), -+ """ -+ <rule id="X-rule" boolean-op="and" score="INFINITY" /> -+ """, -+ ) -+ -+ def test_one_child(self): -+ self.assert_cib( -+ BoolExpr(BOOL_AND, [OpExpr("start", None)]), -+ """ -+ <rule id="X-rule" boolean-op="and" score="INFINITY"> -+ <op_expression id="X-rule-op-start" name="start" /> -+ </rule> -+ """, -+ ) -+ -+ def test_two_children(self): -+ operators = [ -+ (BOOL_OR, "or"), -+ (BOOL_AND, "and"), -+ ] -+ for op_in, op_out in operators: -+ with self.subTest(op_in=op_in, op_out=op_out): -+ self.assert_cib( -+ BoolExpr( -+ op_in, -+ [ -+ OpExpr("start", None), -+ RscExpr("systemd", None, "pcsd"), -+ ], -+ ), -+ f""" -+ <rule id="X-rule" boolean-op="{op_out}" score="INFINITY"> -+ <op_expression id="X-rule-op-start" name="start" /> -+ <rsc_expression id="X-rule-rsc-systemd-pcsd" -+ class="systemd" type="pcsd" -+ /> -+ </rule> -+ """, -+ ) -+ -+ -+class SimpleOp(Base): -+ def test_minimal(self): -+ self.assert_cib( -+ OpExpr("start", None), -+ """ -+ <op_expression id="X-op-start" name="start" /> -+ """, -+ ) -+ -+ def test_interval(self): -+ self.assert_cib( -+ OpExpr("monitor", "2min"), -+ """ -+ <op_expression id="X-op-monitor" name="monitor" -+ interval="2min" -+ /> -+ """, -+ ) -+ -+ -+class SimpleRsc(Base): -+ def test_class(self): -+ self.assert_cib( -+ RscExpr("ocf", None, None), -+ """ -+ <rsc_expression id="X-rsc-ocf" class="ocf" /> -+ """, -+ ) -+ -+ def test_provider(self): -+ self.assert_cib( -+ RscExpr(None, "pacemaker", None), -+ """ -+ <rsc_expression id="X-rsc-pacemaker" provider="pacemaker" /> -+ """, -+ ) -+ -+ def type(self): -+ self.assert_cib( -+ RscExpr(None, None, "Dummy"), -+ """ -+ <rsc_expression id="X-rsc-Dummy" type="Dummy" /> -+ """, -+ ) -+ -+ def test_provider_type(self): -+ self.assert_cib( -+ RscExpr(None, "pacemaker", "Dummy"), -+ """ -+ <rsc_expression id="X-rsc-pacemaker-Dummy" -+ provider="pacemaker" type="Dummy" -+ /> -+ """, -+ ) -+ -+ def test_class_provider(self): -+ self.assert_cib( -+ RscExpr("ocf", "pacemaker", None), -+ """ -+ <rsc_expression id="X-rsc-ocf-pacemaker" -+ class="ocf" provider="pacemaker" -+ /> -+ """, -+ ) -+ -+ def test_class_type(self): -+ self.assert_cib( -+ RscExpr("systemd", None, "pcsd"), -+ """ -+ <rsc_expression id="X-rsc-systemd-pcsd" -+ class="systemd" type="pcsd" -+ /> -+ """, -+ ) -+ -+ def test_class_provider_type(self): -+ self.assert_cib( -+ RscExpr("ocf", "pacemaker", "Dummy"), -+ """ -+ <rsc_expression id="X-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ """, -+ ) -+ -+ -+class Complex(Base): -+ def test_expr_1(self): -+ self.assert_cib( -+ BoolExpr( -+ BOOL_AND, -+ [ -+ BoolExpr( -+ BOOL_OR, -+ [ -+ RscExpr("ocf", "pacemaker", "Dummy"), -+ OpExpr("start", None), -+ RscExpr("systemd", None, "pcsd"), -+ RscExpr("ocf", "heartbeat", "Dummy"), -+ ], -+ ), -+ BoolExpr( -+ BOOL_OR, -+ [ -+ OpExpr("monitor", "30s"), -+ RscExpr("ocf", "pacemaker", "Dummy"), -+ OpExpr("start", None), -+ OpExpr("monitor", "2min"), -+ ], -+ ), -+ ], -+ ), -+ """ -+ <rule id="X-rule" boolean-op="and" score="INFINITY"> -+ <rule id="X-rule-rule" boolean-op="or"> -+ <rsc_expression id="X-rule-rule-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ <op_expression id="X-rule-rule-op-start" name="start" /> -+ <rsc_expression id="X-rule-rule-rsc-systemd-pcsd" -+ class="systemd" type="pcsd" -+ /> -+ <rsc_expression id="X-rule-rule-rsc-ocf-heartbeat-Dummy" -+ class="ocf" provider="heartbeat" type="Dummy" -+ /> -+ </rule> -+ <rule id="X-rule-rule-1" boolean-op="or"> -+ <op_expression id="X-rule-rule-1-op-monitor" -+ name="monitor" interval="30s" -+ /> -+ <rsc_expression id="X-rule-rule-1-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ <op_expression id="X-rule-rule-1-op-start" name="start" /> -+ <op_expression id="X-rule-rule-1-op-monitor-1" -+ name="monitor" interval="2min" -+ /> -+ </rule> -+ </rule> -+ """, -+ ) -diff --git a/pcs_test/tier0/lib/cib/rule/test_parser.py b/pcs_test/tier0/lib/cib/rule/test_parser.py -new file mode 100644 -index 00000000..110fc739 ---- /dev/null -+++ b/pcs_test/tier0/lib/cib/rule/test_parser.py -@@ -0,0 +1,270 @@ -+from dataclasses import fields -+from textwrap import dedent -+from unittest import TestCase -+ -+from pcs.common.str_tools import indent -+from pcs.lib.cib import rule -+from pcs.lib.cib.rule.expression_part import BoolExpr -+ -+ -+def _parsed_to_str(parsed): -+ if isinstance(parsed, BoolExpr): -+ str_args = [] -+ for arg in parsed.children: -+ str_args.extend(_parsed_to_str(arg).splitlines()) -+ return "\n".join( -+ [f"{parsed.__class__.__name__} {parsed.operator}"] -+ + indent(str_args) -+ ) -+ -+ parts = [parsed.__class__.__name__] -+ for field in fields(parsed): -+ value = getattr(parsed, field.name) -+ if value is not None: -+ parts.append(f"{field.name}={value}") -+ return " ".join(parts) -+ -+ -+class Parser(TestCase): -+ def test_success_parse_to_tree(self): -+ test_data = [ -+ ("", "BoolExpr AND"), -+ ( -+ "resource ::", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr""" -+ ), -+ ), -+ ( -+ "resource ::dummy", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr type=dummy""" -+ ), -+ ), -+ ( -+ "resource ocf::", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr standard=ocf""" -+ ), -+ ), -+ ( -+ "resource :pacemaker:", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr provider=pacemaker""" -+ ), -+ ), -+ ( -+ "resource systemd::Dummy", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr standard=systemd type=Dummy""" -+ ), -+ ), -+ ( -+ "resource ocf:pacemaker:", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr standard=ocf provider=pacemaker""" -+ ), -+ ), -+ ( -+ "resource :pacemaker:Dummy", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr provider=pacemaker type=Dummy""" -+ ), -+ ), -+ ( -+ "resource ocf:pacemaker:Dummy", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr standard=ocf provider=pacemaker type=Dummy""" -+ ), -+ ), -+ ( -+ "op monitor", -+ dedent( -+ """\ -+ BoolExpr AND -+ OpExpr name=monitor""" -+ ), -+ ), -+ ( -+ "op monitor interval=10", -+ dedent( -+ """\ -+ BoolExpr AND -+ OpExpr name=monitor interval=10""" -+ ), -+ ), -+ ( -+ "resource ::dummy and op monitor", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr type=dummy -+ OpExpr name=monitor""" -+ ), -+ ), -+ ( -+ "resource ::dummy or op monitor interval=15s", -+ dedent( -+ """\ -+ BoolExpr OR -+ RscExpr type=dummy -+ OpExpr name=monitor interval=15s""" -+ ), -+ ), -+ ( -+ "op monitor and resource ::dummy", -+ dedent( -+ """\ -+ BoolExpr AND -+ OpExpr name=monitor -+ RscExpr type=dummy""" -+ ), -+ ), -+ ( -+ "op monitor interval=5min or resource ::dummy", -+ dedent( -+ """\ -+ BoolExpr OR -+ OpExpr name=monitor interval=5min -+ RscExpr type=dummy""" -+ ), -+ ), -+ ( -+ "(resource ::dummy or resource ::delay) and op monitor", -+ dedent( -+ """\ -+ BoolExpr AND -+ BoolExpr OR -+ RscExpr type=dummy -+ RscExpr type=delay -+ OpExpr name=monitor""" -+ ), -+ ), -+ ( -+ "(op start and op stop) or resource ::dummy", -+ dedent( -+ """\ -+ BoolExpr OR -+ BoolExpr AND -+ OpExpr name=start -+ OpExpr name=stop -+ RscExpr type=dummy""" -+ ), -+ ), -+ ( -+ "op monitor or (resource ::dummy and resource ::delay)", -+ dedent( -+ """\ -+ BoolExpr OR -+ OpExpr name=monitor -+ BoolExpr AND -+ RscExpr type=dummy -+ RscExpr type=delay""" -+ ), -+ ), -+ ( -+ "resource ::dummy and (op start or op stop)", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr type=dummy -+ BoolExpr OR -+ OpExpr name=start -+ OpExpr name=stop""" -+ ), -+ ), -+ ( -+ "resource ::dummy and resource ::delay and op monitor", -+ dedent( -+ """\ -+ BoolExpr AND -+ RscExpr type=dummy -+ RscExpr type=delay -+ OpExpr name=monitor""" -+ ), -+ ), -+ ( -+ "resource ::rA or resource ::rB or resource ::rC and op monitor", -+ dedent( -+ """\ -+ BoolExpr AND -+ BoolExpr OR -+ RscExpr type=rA -+ RscExpr type=rB -+ RscExpr type=rC -+ OpExpr name=monitor""" -+ ), -+ ), -+ ( -+ "op start and op stop and op monitor or resource ::delay", -+ dedent( -+ """\ -+ BoolExpr OR -+ BoolExpr AND -+ OpExpr name=start -+ OpExpr name=stop -+ OpExpr name=monitor -+ RscExpr type=delay""" -+ ), -+ ), -+ ( -+ "(resource ::rA or resource ::rB or resource ::rC) and (op oX or op oY or op oZ)", -+ dedent( -+ """\ -+ BoolExpr AND -+ BoolExpr OR -+ RscExpr type=rA -+ RscExpr type=rB -+ RscExpr type=rC -+ BoolExpr OR -+ OpExpr name=oX -+ OpExpr name=oY -+ OpExpr name=oZ""" -+ ), -+ ), -+ ] -+ for rule_string, rule_tree in test_data: -+ with self.subTest(rule_string=rule_string): -+ self.assertEqual( -+ rule_tree, -+ _parsed_to_str( -+ rule.parse_rule( -+ rule_string, allow_rsc_expr=True, allow_op_expr=True -+ ) -+ ), -+ ) -+ -+ def test_not_valid_rule(self): -+ test_data = [ -+ ("resource", (1, 9, 8, "Expected <resource name>")), -+ ("op", (1, 3, 2, "Expected <operation name>")), -+ ("resource ::rA and", (1, 15, 14, "Expected end of text")), -+ ("resource ::rA and op ", (1, 15, 14, "Expected end of text")), -+ ("resource ::rA and (", (1, 15, 14, "Expected end of text")), -+ ] -+ -+ for rule_string, exception_data in test_data: -+ with self.subTest(rule_string=rule_string): -+ with self.assertRaises(rule.RuleParseError) as cm: -+ rule.parse_rule( -+ rule_string, allow_rsc_expr=True, allow_op_expr=True -+ ) -+ e = cm.exception -+ self.assertEqual(exception_data, (e.lineno, e.colno, e.pos, e.msg)) -+ self.assertEqual(rule_string, e.rule_string) -diff --git a/pcs_test/tier0/lib/cib/rule/test_validator.py b/pcs_test/tier0/lib/cib/rule/test_validator.py -new file mode 100644 -index 00000000..95344a4a ---- /dev/null -+++ b/pcs_test/tier0/lib/cib/rule/test_validator.py -@@ -0,0 +1,68 @@ -+from unittest import TestCase -+ -+from pcs_test.tools import fixture -+from pcs_test.tools.assertions import assert_report_item_list_equal -+ -+from pcs.common import reports -+from pcs.common.types import CibRuleExpressionType -+from pcs.lib.cib.rule.expression_part import ( -+ BOOL_AND, -+ BOOL_OR, -+ BoolExpr, -+ OpExpr, -+ RscExpr, -+) -+from pcs.lib.cib.rule.validator import Validator -+ -+ -+class ValidatorTest(TestCase): -+ def setUp(self): -+ self.report_op = fixture.error( -+ reports.codes.RULE_EXPRESSION_NOT_ALLOWED, -+ expression_type=CibRuleExpressionType.OP_EXPRESSION, -+ ) -+ self.report_rsc = fixture.error( -+ reports.codes.RULE_EXPRESSION_NOT_ALLOWED, -+ expression_type=CibRuleExpressionType.RSC_EXPRESSION, -+ ) -+ self.rule_rsc = BoolExpr( -+ BOOL_OR, [RscExpr(None, None, "a"), RscExpr(None, None, "b")] -+ ) -+ self.rule_op = BoolExpr( -+ BOOL_OR, [OpExpr("start", None), OpExpr("stop", None)] -+ ) -+ self.rule = BoolExpr(BOOL_AND, [self.rule_rsc, self.rule_op]) -+ -+ def test_complex_rule(self): -+ test_data = ( -+ (True, True, []), -+ (True, False, [self.report_rsc]), -+ (False, True, [self.report_op]), -+ (False, False, [self.report_rsc, self.report_op]), -+ ) -+ for op_allowed, rsc_allowed, report_list in test_data: -+ with self.subTest(op_allowed=op_allowed, rsc_allowed=rsc_allowed): -+ assert_report_item_list_equal( -+ Validator( -+ self.rule, -+ allow_rsc_expr=rsc_allowed, -+ allow_op_expr=op_allowed, -+ ).get_reports(), -+ report_list, -+ ) -+ -+ def test_disallow_missing_op(self): -+ assert_report_item_list_equal( -+ Validator( -+ self.rule_rsc, allow_rsc_expr=True, allow_op_expr=False -+ ).get_reports(), -+ [], -+ ) -+ -+ def test_disallow_missing_rsc(self): -+ assert_report_item_list_equal( -+ Validator( -+ self.rule_op, allow_rsc_expr=False, allow_op_expr=True -+ ).get_reports(), -+ [], -+ ) -diff --git a/pcs_test/tier0/lib/cib/test_nvpair_multi.py b/pcs_test/tier0/lib/cib/test_nvpair_multi.py -new file mode 100644 -index 00000000..c68c7233 ---- /dev/null -+++ b/pcs_test/tier0/lib/cib/test_nvpair_multi.py -@@ -0,0 +1,513 @@ -+from unittest import TestCase -+ -+from lxml import etree -+ -+from pcs_test.tools import fixture -+from pcs_test.tools.assertions import ( -+ assert_report_item_list_equal, -+ assert_xml_equal, -+) -+from pcs_test.tools.xml import etree_to_str -+ -+from pcs.common import reports -+from pcs.common.pacemaker.nvset import ( -+ CibNvpairDto, -+ CibNvsetDto, -+) -+from pcs.common.pacemaker.rule import CibRuleExpressionDto -+from pcs.common.types import ( -+ CibNvsetType, -+ CibRuleExpressionType, -+) -+from pcs.lib.cib import nvpair_multi -+from pcs.lib.cib.rule.expression_part import ( -+ BOOL_AND, -+ BoolExpr, -+ OpExpr, -+ RscExpr, -+) -+from pcs.lib.cib.tools import IdProvider -+ -+ -+class NvpairElementToDto(TestCase): -+ def test_success(self): -+ xml = etree.fromstring( -+ """ -+ <nvpair id="my-id" name="my-name" value="my-value" /> -+ """ -+ ) -+ self.assertEqual( -+ nvpair_multi.nvpair_element_to_dto(xml), -+ CibNvpairDto("my-id", "my-name", "my-value"), -+ ) -+ -+ -+class NvsetElementToDto(TestCase): -+ tag_type = ( -+ ("meta_attributes", CibNvsetType.META), -+ ("instance_attributes", CibNvsetType.INSTANCE), -+ ) -+ -+ def test_minimal(self): -+ for tag, nvtype in self.tag_type: -+ with self.subTest(tag=tag, nvset_type=nvtype): -+ xml = etree.fromstring(f"""<{tag} id="my-id" />""") -+ self.assertEqual( -+ nvpair_multi.nvset_element_to_dto(xml), -+ CibNvsetDto("my-id", nvtype, {}, None, []), -+ ) -+ -+ def test_full(self): -+ for tag, nvtype in self.tag_type: -+ with self.subTest(tag=tag, nvset_type=nvtype): -+ xml = etree.fromstring( -+ f""" -+ <{tag} id="my-id" score="150"> -+ <rule id="my-id-rule" boolean-op="or"> -+ <op_expression id="my-id-rule-op" name="monitor" /> -+ </rule> -+ <nvpair id="my-id-pair1" name="name1" value="value1" /> -+ <nvpair id="my-id-pair2" name="name2" value="value2" /> -+ </{tag}> -+ """ -+ ) -+ self.assertEqual( -+ nvpair_multi.nvset_element_to_dto(xml), -+ CibNvsetDto( -+ "my-id", -+ nvtype, -+ {"score": "150"}, -+ CibRuleExpressionDto( -+ "my-id-rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "or"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ "my-id-rule-op", -+ CibRuleExpressionType.OP_EXPRESSION, -+ False, -+ {"name": "monitor"}, -+ None, -+ None, -+ [], -+ "op monitor", -+ ), -+ ], -+ "op monitor", -+ ), -+ [ -+ CibNvpairDto("my-id-pair1", "name1", "value1"), -+ CibNvpairDto("my-id-pair2", "name2", "value2"), -+ ], -+ ), -+ ) -+ -+ -+class FindNvsets(TestCase): -+ def test_empty(self): -+ xml = etree.fromstring("<parent />") -+ self.assertEqual([], nvpair_multi.find_nvsets(xml)) -+ -+ def test_full(self): -+ xml = etree.fromstring( -+ """ -+ <parent> -+ <meta_attributes id="set1" /> -+ <instance_attributes id="set2" /> -+ <not_an_nvset id="set3" /> -+ </parent> -+ """ -+ ) -+ self.assertEqual( -+ ["set1", "set2"], -+ [el.get("id") for el in nvpair_multi.find_nvsets(xml)], -+ ) -+ -+ -+class FindNvsetsByIds(TestCase): -+ def test_success(self): -+ xml = etree.fromstring( -+ """ -+ <parent> -+ <meta_attributes id="set1" /> -+ <instance_attributes id="set2" /> -+ <not_an_nvset id="set3" /> -+ <meta_attributes id="set4" /> -+ </parent> -+ """ -+ ) -+ element_list, report_list = nvpair_multi.find_nvsets_by_ids( -+ xml, ["set1", "set2", "set3", "setX"] -+ ) -+ self.assertEqual( -+ ["set1", "set2"], [el.get("id") for el in element_list], -+ ) -+ assert_report_item_list_equal( -+ report_list, -+ [ -+ fixture.report_unexpected_element( -+ "set3", "not_an_nvset", ["options set"] -+ ), -+ fixture.report_not_found( -+ "setX", -+ context_type="parent", -+ expected_types=["options set"], -+ ), -+ ], -+ ) -+ -+ -+class ValidateNvsetAppendNew(TestCase): -+ def setUp(self): -+ self.id_provider = IdProvider( -+ etree.fromstring("""<cib><tags><tag id="a" /></tags></cib>""") -+ ) -+ -+ def test_success_minimal(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, {}, {} -+ ) -+ assert_report_item_list_equal( -+ validator.validate(force_options=True), [] -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ def test_success_full(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, -+ {"name": "value"}, -+ {"id": "some-id", "score": "10"}, -+ nvset_rule="resource ::stateful", -+ rule_allows_rsc_expr=True, -+ rule_allows_op_expr=True, -+ ) -+ assert_report_item_list_equal( -+ validator.validate(), [], -+ ) -+ self.assertEqual( -+ repr(validator.get_parsed_rule()), -+ "BoolExpr(operator='AND', children=[" -+ "RscExpr(standard=None, provider=None, type='stateful')" -+ "])", -+ ) -+ -+ def test_id_not_valid(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, {}, {"id": "123"} -+ ) -+ assert_report_item_list_equal( -+ validator.validate(force_options=True), -+ [fixture.report_invalid_id("123", "1")], -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ def test_id_not_available(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, {}, {"id": "a"} -+ ) -+ assert_report_item_list_equal( -+ validator.validate(force_options=True), -+ [fixture.error(reports.codes.ID_ALREADY_EXISTS, id="a")], -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ def test_score_not_valid(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, {}, {"score": "a"} -+ ) -+ assert_report_item_list_equal( -+ validator.validate(force_options=True), -+ [fixture.error(reports.codes.INVALID_SCORE, score="a")], -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ def test_options_names(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, {}, {"not_valid": "a"} -+ ) -+ assert_report_item_list_equal( -+ validator.validate(), -+ [ -+ fixture.error( -+ reports.codes.INVALID_OPTIONS, -+ force_code=reports.codes.FORCE_OPTIONS, -+ option_names=["not_valid"], -+ allowed=["id", "score"], -+ option_type=None, -+ allowed_patterns=[], -+ ), -+ ], -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ def test_options_names_forced(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, {}, {"not_valid": "a"} -+ ) -+ assert_report_item_list_equal( -+ validator.validate(force_options=True), -+ [ -+ fixture.warn( -+ reports.codes.INVALID_OPTIONS, -+ option_names=["not_valid"], -+ allowed=["id", "score"], -+ option_type=None, -+ allowed_patterns=[], -+ ), -+ ], -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ def test_rule_not_valid(self): -+ validator = nvpair_multi.ValidateNvsetAppendNew( -+ self.id_provider, -+ {}, -+ {}, -+ "bad rule", -+ rule_allows_rsc_expr=True, -+ rule_allows_op_expr=True, -+ ) -+ assert_report_item_list_equal( -+ validator.validate(force_options=True), -+ [ -+ fixture.error( -+ reports.codes.RULE_EXPRESSION_PARSE_ERROR, -+ rule_string="bad rule", -+ reason='Expected "resource"', -+ rule_line="bad rule", -+ line_number=1, -+ column_number=1, -+ position=0, -+ ), -+ ], -+ ) -+ self.assertIsNone(validator.get_parsed_rule()) -+ -+ -+class NvsetAppendNew(TestCase): -+ # pylint: disable=no-self-use -+ def test_minimal(self): -+ context_element = etree.fromstring("""<context id="a" />""") -+ id_provider = IdProvider(context_element) -+ nvpair_multi.nvset_append_new( -+ context_element, id_provider, nvpair_multi.NVSET_META, {}, {} -+ ) -+ assert_xml_equal( -+ """ -+ <context id="a"> -+ <meta_attributes id="a-meta_attributes" /> -+ </context> -+ """, -+ etree_to_str(context_element), -+ ) -+ -+ def test_nvpairs(self): -+ context_element = etree.fromstring("""<context id="a" />""") -+ id_provider = IdProvider(context_element) -+ nvpair_multi.nvset_append_new( -+ context_element, -+ id_provider, -+ nvpair_multi.NVSET_META, -+ {"attr1": "value1", "attr-empty": "", "attr2": "value2"}, -+ {}, -+ ) -+ assert_xml_equal( -+ """ -+ <context id="a"> -+ <meta_attributes id="a-meta_attributes"> -+ <nvpair id="a-meta_attributes-attr1" -+ name="attr1" value="value1" -+ /> -+ <nvpair id="a-meta_attributes-attr2" -+ name="attr2" value="value2" -+ /> -+ </meta_attributes> -+ </context> -+ """, -+ etree_to_str(context_element), -+ ) -+ -+ def test_rule(self): -+ context_element = etree.fromstring("""<context id="a" />""") -+ id_provider = IdProvider(context_element) -+ nvpair_multi.nvset_append_new( -+ context_element, -+ id_provider, -+ nvpair_multi.NVSET_META, -+ {}, -+ {}, -+ nvset_rule=BoolExpr( -+ BOOL_AND, -+ [RscExpr("ocf", "pacemaker", "Dummy"), OpExpr("start", None)], -+ ), -+ ) -+ assert_xml_equal( -+ """ -+ <context id="a"> -+ <meta_attributes id="a-meta_attributes"> -+ <rule id="a-meta_attributes-rule" -+ boolean-op="and" score="INFINITY" -+ > -+ <rsc_expression -+ id="a-meta_attributes-rule-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ <op_expression id="a-meta_attributes-rule-op-start" -+ name="start" -+ /> -+ </rule> -+ </meta_attributes> -+ </context> -+ """, -+ etree_to_str(context_element), -+ ) -+ -+ def test_custom_id(self): -+ context_element = etree.fromstring("""<context id="a" />""") -+ id_provider = IdProvider(context_element) -+ nvpair_multi.nvset_append_new( -+ context_element, -+ id_provider, -+ nvpair_multi.NVSET_META, -+ {}, -+ {"id": "custom-id"}, -+ ) -+ assert_xml_equal( -+ """ -+ <context id="a"> -+ <meta_attributes id="custom-id" /> -+ </context> -+ """, -+ etree_to_str(context_element), -+ ) -+ -+ def test_options(self): -+ context_element = etree.fromstring("""<context id="a" />""") -+ id_provider = IdProvider(context_element) -+ nvpair_multi.nvset_append_new( -+ context_element, -+ id_provider, -+ nvpair_multi.NVSET_META, -+ {}, -+ {"score": "INFINITY", "empty-attr": ""}, -+ ) -+ assert_xml_equal( -+ """ -+ <context id="a"> -+ <meta_attributes id="a-meta_attributes" score="INFINITY" /> -+ </context> -+ """, -+ etree_to_str(context_element), -+ ) -+ -+ def test_everything(self): -+ context_element = etree.fromstring("""<context id="a" />""") -+ id_provider = IdProvider(context_element) -+ nvpair_multi.nvset_append_new( -+ context_element, -+ id_provider, -+ nvpair_multi.NVSET_META, -+ {"attr1": "value1", "attr-empty": "", "attr2": "value2"}, -+ {"id": "custom-id", "score": "INFINITY", "empty-attr": ""}, -+ nvset_rule=BoolExpr( -+ BOOL_AND, -+ [RscExpr("ocf", "pacemaker", "Dummy"), OpExpr("start", None)], -+ ), -+ ) -+ assert_xml_equal( -+ """ -+ <context id="a"> -+ <meta_attributes id="custom-id" score="INFINITY"> -+ <rule id="custom-id-rule" -+ boolean-op="and" score="INFINITY" -+ > -+ <rsc_expression id="custom-id-rule-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ <op_expression id="custom-id-rule-op-start" -+ name="start" -+ /> -+ </rule> -+ <nvpair id="custom-id-attr1" -+ name="attr1" value="value1" -+ /> -+ <nvpair id="custom-id-attr2" -+ name="attr2" value="value2" -+ /> -+ </meta_attributes> -+ </context> -+ """, -+ etree_to_str(context_element), -+ ) -+ -+ -+class NvsetRemove(TestCase): -+ # pylint: disable=no-self-use -+ def test_success(self): -+ xml = etree.fromstring( -+ """ -+ <parent> -+ <meta_attributes id="set1" /> -+ <instance_attributes id="set2" /> -+ <not_an_nvset id="set3" /> -+ <meta_attributes id="set4" /> -+ </parent> -+ """ -+ ) -+ nvpair_multi.nvset_remove( -+ [xml.find(".//*[@id='set2']"), xml.find(".//*[@id='set4']")] -+ ) -+ assert_xml_equal( -+ """ -+ <parent> -+ <meta_attributes id="set1" /> -+ <not_an_nvset id="set3" /> -+ </parent> -+ """, -+ etree_to_str(xml), -+ ) -+ -+ -+class NvsetUpdate(TestCase): -+ # pylint: disable=no-self-use -+ def test_success_nvpair_all_cases(self): -+ nvset_element = etree.fromstring( -+ """ -+ <meta_attributes id="set1"> -+ <nvpair id="pair1" name="name1" value="value1" /> -+ <nvpair id="pair2" name="name2" value="value2" /> -+ <nvpair id="pair3" name="name 3" value="value 3" /> -+ <nvpair id="pair4" name="name4" value="value4" /> -+ <nvpair id="pair4A" name="name4" value="value4A" /> -+ <nvpair id="pair4B" name="name4" value="value4B" /> -+ </meta_attributes> -+ """ -+ ) -+ id_provider = IdProvider(nvset_element) -+ nvpair_multi.nvset_update( -+ nvset_element, -+ id_provider, -+ { -+ "name2": "", # delete -+ "name 3": "value 3 new", # change and escaping spaces -+ "name4": "value4new", # change and make unique -+ "name5": "", # do not add empty -+ "name'6'": 'value"6"', # escaping -+ }, -+ ) -+ assert_xml_equal( -+ """ -+ <meta_attributes id="set1"> -+ <nvpair id="pair1" name="name1" value="value1" /> -+ <nvpair id="pair3" name="name 3" value="value 3 new" /> -+ <nvpair id="pair4" name="name4" value="value4new" /> -+ <nvpair id="set1-name6" -+ name="name'6'" value="value"6"" -+ /> -+ </meta_attributes> -+ """, -+ etree_to_str(nvset_element), -+ ) -diff --git a/pcs_test/tier0/lib/cib/test_tools.py b/pcs_test/tier0/lib/cib/test_tools.py -index 376012a1..56f29148 100644 ---- a/pcs_test/tier0/lib/cib/test_tools.py -+++ b/pcs_test/tier0/lib/cib/test_tools.py -@@ -233,8 +233,8 @@ class FindUniqueIdTest(CibToolsTest): - ) - - --class CreateNvsetIdTest(TestCase): -- def test_create_plain_id_when_no_confilicting_id_there(self): -+class CreateSubelementId(TestCase): -+ def test_create_plain_id_when_no_conflicting_id_there(self): - context = etree.fromstring('<cib><a id="b"/></cib>') - self.assertEqual( - "b-name", -@@ -252,6 +252,15 @@ class CreateNvsetIdTest(TestCase): - ), - ) - -+ def test_parent_has_no_id(self): -+ context = etree.fromstring("<cib><a/></cib>") -+ self.assertEqual( -+ "a-name", -+ lib.create_subelement_id( -+ context.find(".//a"), "name", lib.IdProvider(context) -+ ), -+ ) -+ - - class GetConfigurationTest(CibToolsTest): - def test_success_if_exists(self): -diff --git a/pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py b/pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py -deleted file mode 100644 -index 2542043a..00000000 ---- a/pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py -+++ /dev/null -@@ -1,120 +0,0 @@ --from unittest import TestCase -- --from pcs_test.tools import fixture --from pcs_test.tools.command_env import get_env_tools -- --from pcs.common.reports import codes as report_codes --from pcs.lib.commands import cib_options -- --FIXTURE_INITIAL_DEFAULTS = """ -- <op_defaults> -- <meta_attributes id="op_defaults-options"> -- <nvpair id="op_defaults-options-a" name="a" value="b"/> -- <nvpair id="op_defaults-options-b" name="b" value="c"/> -- </meta_attributes> -- </op_defaults> --""" -- -- --class SetOperationsDefaults(TestCase): -- def setUp(self): -- self.env_assist, self.config = get_env_tools(test_case=self) -- -- def tearDown(self): -- self.env_assist.assert_reports( -- [fixture.warn(report_codes.DEFAULTS_CAN_BE_OVERRIDEN)] -- ) -- -- def test_change(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib( -- optional_in_conf=""" -- <op_defaults> -- <meta_attributes id="op_defaults-options"> -- <nvpair id="op_defaults-options-a" name="a" value="B"/> -- <nvpair id="op_defaults-options-b" name="b" value="C"/> -- </meta_attributes> -- </op_defaults> -- """ -- ) -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": "B", "b": "C",} -- ) -- -- def test_add(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib( -- optional_in_conf=""" -- <op_defaults> -- <meta_attributes id="op_defaults-options"> -- <nvpair id="op_defaults-options-a" name="a" value="b"/> -- <nvpair id="op_defaults-options-b" name="b" value="c"/> -- <nvpair id="op_defaults-options-c" name="c" value="d"/> -- </meta_attributes> -- </op_defaults> -- """ -- ) -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"c": "d"}, -- ) -- -- def test_remove(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib( -- remove=( -- "./configuration/op_defaults/meta_attributes/nvpair[@name='a']" -- ) -- ) -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": ""}, -- ) -- -- def test_add_section_if_missing(self): -- self.config.runner.cib.load() -- self.config.env.push_cib( -- optional_in_conf=""" -- <op_defaults> -- <meta_attributes id="op_defaults-options"> -- <nvpair id="op_defaults-options-a" name="a" value="A"/> -- </meta_attributes> -- </op_defaults> -- """ -- ) -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": "A",} -- ) -- -- def test_add_meta_if_missing(self): -- self.config.runner.cib.load(optional_in_conf="<op_defaults />") -- self.config.env.push_cib( -- optional_in_conf=""" -- <op_defaults> -- <meta_attributes id="op_defaults-options"> -- <nvpair id="op_defaults-options-a" name="a" value="A"/> -- </meta_attributes> -- </op_defaults> -- """ -- ) -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": "A",} -- ) -- -- def test_dont_add_section_if_only_removing(self): -- self.config.runner.cib.load() -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": "", "b": "",} -- ) -- -- def test_dont_add_meta_if_only_removing(self): -- self.config.runner.cib.load(optional_in_conf="<op_defaults />") -- self.config.env.push_cib(optional_in_conf="<op_defaults />") -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": "", "b": "",} -- ) -- -- def test_keep_section_when_empty(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib(remove="./configuration/op_defaults//nvpair") -- cib_options.set_operations_defaults( -- self.env_assist.get_env(), {"a": "", "b": "",} -- ) -diff --git a/pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py b/pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py -deleted file mode 100644 -index 51d8abf4..00000000 ---- a/pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py -+++ /dev/null -@@ -1,120 +0,0 @@ --from unittest import TestCase -- --from pcs_test.tools import fixture --from pcs_test.tools.command_env import get_env_tools -- --from pcs.common.reports import codes as report_codes --from pcs.lib.commands import cib_options -- --FIXTURE_INITIAL_DEFAULTS = """ -- <rsc_defaults> -- <meta_attributes id="rsc_defaults-options"> -- <nvpair id="rsc_defaults-options-a" name="a" value="b"/> -- <nvpair id="rsc_defaults-options-b" name="b" value="c"/> -- </meta_attributes> -- </rsc_defaults> --""" -- -- --class SetResourcesDefaults(TestCase): -- def setUp(self): -- self.env_assist, self.config = get_env_tools(test_case=self) -- -- def tearDown(self): -- self.env_assist.assert_reports( -- [fixture.warn(report_codes.DEFAULTS_CAN_BE_OVERRIDEN)] -- ) -- -- def test_change(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib( -- optional_in_conf=""" -- <rsc_defaults> -- <meta_attributes id="rsc_defaults-options"> -- <nvpair id="rsc_defaults-options-a" name="a" value="B"/> -- <nvpair id="rsc_defaults-options-b" name="b" value="C"/> -- </meta_attributes> -- </rsc_defaults> -- """ -- ) -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": "B", "b": "C",} -- ) -- -- def test_add(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib( -- optional_in_conf=""" -- <rsc_defaults> -- <meta_attributes id="rsc_defaults-options"> -- <nvpair id="rsc_defaults-options-a" name="a" value="b"/> -- <nvpair id="rsc_defaults-options-b" name="b" value="c"/> -- <nvpair id="rsc_defaults-options-c" name="c" value="d"/> -- </meta_attributes> -- </rsc_defaults> -- """ -- ) -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"c": "d"}, -- ) -- -- def test_remove(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib( -- remove=( -- "./configuration/rsc_defaults/meta_attributes/nvpair[@name='a']" -- ) -- ) -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": ""}, -- ) -- -- def test_add_section_if_missing(self): -- self.config.runner.cib.load() -- self.config.env.push_cib( -- optional_in_conf=""" -- <rsc_defaults> -- <meta_attributes id="rsc_defaults-options"> -- <nvpair id="rsc_defaults-options-a" name="a" value="A"/> -- </meta_attributes> -- </rsc_defaults> -- """ -- ) -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": "A",} -- ) -- -- def test_add_meta_if_missing(self): -- self.config.runner.cib.load(optional_in_conf="<rsc_defaults />") -- self.config.env.push_cib( -- optional_in_conf=""" -- <rsc_defaults> -- <meta_attributes id="rsc_defaults-options"> -- <nvpair id="rsc_defaults-options-a" name="a" value="A"/> -- </meta_attributes> -- </rsc_defaults> -- """ -- ) -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": "A",} -- ) -- -- def test_dont_add_section_if_only_removing(self): -- self.config.runner.cib.load() -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": "", "b": "",} -- ) -- -- def test_dont_add_meta_if_only_removing(self): -- self.config.runner.cib.load(optional_in_conf="<rsc_defaults />") -- self.config.env.push_cib(optional_in_conf="<rsc_defaults />") -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": "", "b": "",} -- ) -- -- def test_keep_section_when_empty(self): -- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS) -- self.config.env.push_cib(remove="./configuration/rsc_defaults//nvpair") -- cib_options.set_resources_defaults( -- self.env_assist.get_env(), {"a": "", "b": "",} -- ) -diff --git a/pcs_test/tier0/lib/commands/test_cib_options.py b/pcs_test/tier0/lib/commands/test_cib_options.py -new file mode 100644 -index 00000000..c7c8cb1f ---- /dev/null -+++ b/pcs_test/tier0/lib/commands/test_cib_options.py -@@ -0,0 +1,669 @@ -+from unittest import TestCase -+ -+from pcs_test.tools import fixture -+from pcs_test.tools.command_env import get_env_tools -+ -+from pcs.common import reports -+from pcs.common.pacemaker.nvset import ( -+ CibNvpairDto, -+ CibNvsetDto, -+) -+from pcs.common.pacemaker.rule import CibRuleExpressionDto -+from pcs.common.types import ( -+ CibNvsetType, -+ CibRuleExpressionType, -+) -+from pcs.lib.commands import cib_options -+ -+ -+class DefaultsCreateMixin: -+ command = lambda *args, **kwargs: None -+ tag = "" -+ -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.env_assist, self.config = get_env_tools(self) -+ self.config.runner.cib.load(filename="cib-empty-1.2.xml") -+ -+ def test_success_minimal(self): -+ defaults_xml = f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes" /> -+ </{self.tag}> -+ """ -+ self.config.env.push_cib(optional_in_conf=defaults_xml) -+ -+ self.command(self.env_assist.get_env(), {}, {}) -+ -+ self.env_assist.assert_reports( -+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)] -+ ) -+ -+ def test_success_one_set_already_there(self): -+ defaults_xml_1 = f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes" /> -+ </{self.tag}> -+ """ -+ defaults_xml_2 = f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes" /> -+ <meta_attributes id="{self.tag}-meta_attributes-1" /> -+ </{self.tag}> -+ """ -+ self.config.runner.cib.load( -+ instead="runner.cib.load", optional_in_conf=defaults_xml_1 -+ ) -+ self.config.env.push_cib(optional_in_conf=defaults_xml_2) -+ -+ self.command(self.env_assist.get_env(), {}, {}) -+ -+ self.env_assist.assert_reports( -+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)] -+ ) -+ -+ def test_success_cib_upgrade(self): -+ defaults_xml = f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes"> -+ <rule id="{self.tag}-meta_attributes-rule" -+ boolean-op="and" score="INFINITY" -+ > -+ <rsc_expression -+ id="{self.tag}-meta_attributes-rule-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ </rule> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ self.config.runner.cib.load( -+ name="load_cib_old_version", -+ filename="cib-empty-3.3.xml", -+ before="runner.cib.load", -+ ) -+ self.config.runner.cib.upgrade(before="runner.cib.load") -+ self.config.runner.cib.load( -+ filename="cib-empty-3.4.xml", instead="runner.cib.load" -+ ) -+ self.config.env.push_cib(optional_in_conf=defaults_xml) -+ -+ self.command( -+ self.env_assist.get_env(), -+ {}, -+ {}, -+ nvset_rule="resource ocf:pacemaker:Dummy", -+ ) -+ -+ self.env_assist.assert_reports( -+ [ -+ fixture.info(reports.codes.CIB_UPGRADE_SUCCESSFUL), -+ fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN), -+ ] -+ ) -+ -+ def test_success_full(self): -+ defaults_xml = f""" -+ <{self.tag}> -+ <meta_attributes id="my-id" score="10"> -+ <rule id="my-id-rule" boolean-op="and" score="INFINITY"> -+ <rsc_expression id="my-id-rule-rsc-ocf-pacemaker-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ </rule> -+ <nvpair id="my-id-name1" name="name1" value="value1" /> -+ <nvpair id="my-id-2name" name="2na#me" value="value2" /> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ self.config.runner.cib.load( -+ filename="cib-empty-3.4.xml", instead="runner.cib.load" -+ ) -+ self.config.env.push_cib(optional_in_conf=defaults_xml) -+ -+ self.command( -+ self.env_assist.get_env(), -+ {"name1": "value1", "2na#me": "value2"}, -+ {"id": "my-id", "score": "10"}, -+ nvset_rule="resource ocf:pacemaker:Dummy", -+ ) -+ -+ self.env_assist.assert_reports( -+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)] -+ ) -+ -+ def test_validation(self): -+ self.config.runner.cib.load( -+ filename="cib-empty-3.4.xml", instead="runner.cib.load" -+ ) -+ self.env_assist.assert_raise_library_error( -+ lambda: self.command( -+ self.env_assist.get_env(), -+ {}, -+ {"unknown-option": "value"}, -+ "bad rule", -+ ) -+ ) -+ self.env_assist.assert_reports( -+ [ -+ fixture.error( -+ reports.codes.INVALID_OPTIONS, -+ force_code=reports.codes.FORCE_OPTIONS, -+ option_names=["unknown-option"], -+ allowed=["id", "score"], -+ option_type=None, -+ allowed_patterns=[], -+ ), -+ fixture.error( -+ reports.codes.RULE_EXPRESSION_PARSE_ERROR, -+ rule_string="bad rule", -+ reason='Expected "resource"', -+ rule_line="bad rule", -+ line_number=1, -+ column_number=1, -+ position=0, -+ ), -+ ] -+ ) -+ -+ def test_validation_forced(self): -+ defaults_xml = f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes" -+ unknown-option="value" -+ /> -+ </{self.tag}> -+ """ -+ self.config.env.push_cib(optional_in_conf=defaults_xml) -+ -+ self.command( -+ self.env_assist.get_env(), -+ {}, -+ {"unknown-option": "value"}, -+ force_flags={reports.codes.FORCE_OPTIONS}, -+ ) -+ -+ self.env_assist.assert_reports( -+ [ -+ fixture.warn( -+ reports.codes.INVALID_OPTIONS, -+ option_names=["unknown-option"], -+ allowed=["id", "score"], -+ option_type=None, -+ allowed_patterns=[], -+ ), -+ fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN), -+ ] -+ ) -+ -+ -+class ResourceDefaultsCreate(DefaultsCreateMixin, TestCase): -+ command = staticmethod(cib_options.resource_defaults_create) -+ tag = "rsc_defaults" -+ -+ def test_rule_op_expression_not_allowed(self): -+ self.config.runner.cib.load( -+ filename="cib-empty-3.4.xml", instead="runner.cib.load" -+ ) -+ self.env_assist.assert_raise_library_error( -+ lambda: self.command( -+ self.env_assist.get_env(), {}, {}, "op monitor" -+ ) -+ ) -+ self.env_assist.assert_reports( -+ [ -+ fixture.error( -+ reports.codes.RULE_EXPRESSION_NOT_ALLOWED, -+ expression_type=CibRuleExpressionType.OP_EXPRESSION, -+ ), -+ ] -+ ) -+ -+ -+class OperationDefaultsCreate(DefaultsCreateMixin, TestCase): -+ command = staticmethod(cib_options.operation_defaults_create) -+ tag = "op_defaults" -+ -+ -+class DefaultsConfigMixin: -+ command = lambda *args, **kwargs: None -+ tag = "" -+ -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.env_assist, self.config = get_env_tools(self) -+ -+ def test_empty(self): -+ defaults_xml = f"""<{self.tag} />""" -+ self.config.runner.cib.load( -+ filename="cib-empty-3.4.xml", optional_in_conf=defaults_xml -+ ) -+ self.assertEqual([], self.command(self.env_assist.get_env())) -+ -+ def test_full(self): -+ defaults_xml = f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes"> -+ <rule id="{self.tag}-meta_attributes-rule" -+ boolean-op="and" score="INFINITY" -+ > -+ <rsc_expression -+ id="{self.tag}-meta_attributes-rule-rsc-Dummy" -+ class="ocf" provider="pacemaker" type="Dummy" -+ /> -+ </rule> -+ <nvpair id="my-id-pair1" name="name1" value="value1" /> -+ <nvpair id="my-id-pair2" name="name2" value="value2" /> -+ </meta_attributes> -+ <instance_attributes id="instance"> -+ <nvpair id="instance-pair" name="inst" value="ance" /> -+ </instance_attributes> -+ <meta_attributes id="meta-plain" score="123"> -+ <nvpair id="my-id-pair3" name="name1" value="value1" /> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ self.config.runner.cib.load( -+ filename="cib-empty-3.4.xml", optional_in_conf=defaults_xml -+ ) -+ self.assertEqual( -+ [ -+ CibNvsetDto( -+ f"{self.tag}-meta_attributes", -+ CibNvsetType.META, -+ {}, -+ CibRuleExpressionDto( -+ f"{self.tag}-meta_attributes-rule", -+ CibRuleExpressionType.RULE, -+ False, -+ {"boolean-op": "and", "score": "INFINITY"}, -+ None, -+ None, -+ [ -+ CibRuleExpressionDto( -+ f"{self.tag}-meta_attributes-rule-rsc-Dummy", -+ CibRuleExpressionType.RSC_EXPRESSION, -+ False, -+ { -+ "class": "ocf", -+ "provider": "pacemaker", -+ "type": "Dummy", -+ }, -+ None, -+ None, -+ [], -+ "resource ocf:pacemaker:Dummy", -+ ), -+ ], -+ "resource ocf:pacemaker:Dummy", -+ ), -+ [ -+ CibNvpairDto("my-id-pair1", "name1", "value1"), -+ CibNvpairDto("my-id-pair2", "name2", "value2"), -+ ], -+ ), -+ CibNvsetDto( -+ "instance", -+ CibNvsetType.INSTANCE, -+ {}, -+ None, -+ [CibNvpairDto("instance-pair", "inst", "ance")], -+ ), -+ CibNvsetDto( -+ "meta-plain", -+ CibNvsetType.META, -+ {"score": "123"}, -+ None, -+ [CibNvpairDto("my-id-pair3", "name1", "value1")], -+ ), -+ ], -+ self.command(self.env_assist.get_env()), -+ ) -+ -+ -+class ResourceDefaultsConfig(DefaultsConfigMixin, TestCase): -+ command = staticmethod(cib_options.resource_defaults_config) -+ tag = "rsc_defaults" -+ -+ -+class OperationDefaultsConfig(DefaultsConfigMixin, TestCase): -+ command = staticmethod(cib_options.operation_defaults_config) -+ tag = "op_defaults" -+ -+ -+class DefaultsRemoveMixin: -+ command = lambda *args, **kwargs: None -+ tag = "" -+ -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.env_assist, self.config = get_env_tools(self) -+ -+ def test_nothing_to_delete(self): -+ self.command(self.env_assist.get_env(), []) -+ -+ def test_defaults_section_missing(self): -+ self.config.runner.cib.load(filename="cib-empty-1.2.xml") -+ self.env_assist.assert_raise_library_error( -+ lambda: self.command(self.env_assist.get_env(), ["set1"]) -+ ) -+ self.env_assist.assert_reports( -+ [ -+ fixture.report_not_found( -+ "set1", -+ context_type=self.tag, -+ expected_types=["options set"], -+ ), -+ ] -+ ) -+ -+ def test_success(self): -+ self.config.runner.cib.load( -+ filename="cib-empty-1.2.xml", -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="set1" /> -+ <instance_attributes id="set2" /> -+ <not_an_nvset id="set3" /> -+ <meta_attributes id="set4" /> -+ <instance_attributes id="set5" /> -+ </{self.tag}> -+ """, -+ ) -+ self.config.env.push_cib( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="set1" /> -+ <not_an_nvset id="set3" /> -+ <meta_attributes id="set4" /> -+ </{self.tag}> -+ """ -+ ) -+ self.command(self.env_assist.get_env(), ["set2", "set5"]) -+ -+ def test_delete_all_keep_the_section(self): -+ self.config.runner.cib.load( -+ filename="cib-empty-1.2.xml", -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="set1" /> -+ </{self.tag}> -+ """, -+ ) -+ self.config.env.push_cib(optional_in_conf=f"<{self.tag} />") -+ self.command(self.env_assist.get_env(), ["set1"]) -+ -+ def test_nvset_not_found(self): -+ self.config.runner.cib.load( -+ filename="cib-empty-1.2.xml", -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="set1" /> -+ <instance_attributes id="set2" /> -+ <not_an_nvset id="set3" /> -+ <meta_attributes id="set4" /> -+ <instance_attributes id="set5" /> -+ </{self.tag}> -+ """, -+ ) -+ self.env_assist.assert_raise_library_error( -+ lambda: self.command( -+ self.env_assist.get_env(), ["set2", "set3", "setX"] -+ ) -+ ) -+ self.env_assist.assert_reports( -+ [ -+ fixture.report_unexpected_element( -+ "set3", "not_an_nvset", ["options set"] -+ ), -+ fixture.report_not_found( -+ "setX", -+ context_type=self.tag, -+ expected_types=["options set"], -+ ), -+ ] -+ ) -+ -+ -+class ResourceDefaultsRemove(DefaultsRemoveMixin, TestCase): -+ command = staticmethod(cib_options.resource_defaults_remove) -+ tag = "rsc_defaults" -+ -+ -+class OperationDefaultsRemove(DefaultsRemoveMixin, TestCase): -+ command = staticmethod(cib_options.operation_defaults_remove) -+ tag = "op_defaults" -+ -+ -+class DefaultsUpdateLegacyMixin: -+ # This class tests legacy use cases of not providing an nvset ID -+ command = lambda *args, **kwargs: None -+ tag = "" -+ command_for_report = None -+ -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.env_assist, self.config = get_env_tools(self) -+ self.reports = [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)] -+ -+ def tearDown(self): -+ # pylint: disable=invalid-name -+ self.env_assist.assert_reports(self.reports) -+ -+ def fixture_initial_defaults(self): -+ return f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-options"> -+ <nvpair id="{self.tag}-options-a" name="a" value="b"/> -+ <nvpair id="{self.tag}-options-b" name="b" value="c"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ -+ def test_change(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.config.env.push_cib( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-options"> -+ <nvpair id="{self.tag}-options-a" name="a" value="B"/> -+ <nvpair id="{self.tag}-options-b" name="b" value="C"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ ) -+ self.command(self.env_assist.get_env(), None, {"a": "B", "b": "C"}) -+ -+ def test_add(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.config.env.push_cib( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-options"> -+ <nvpair id="{self.tag}-options-a" name="a" value="b"/> -+ <nvpair id="{self.tag}-options-b" name="b" value="c"/> -+ <nvpair id="{self.tag}-options-c" name="c" value="d"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ ) -+ self.command(self.env_assist.get_env(), None, {"c": "d"}) -+ -+ def test_remove(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.config.env.push_cib( -+ remove=( -+ f"./configuration/{self.tag}/meta_attributes/nvpair[@name='a']" -+ ) -+ ) -+ self.command(self.env_assist.get_env(), None, {"a": ""}) -+ -+ def test_add_section_if_missing(self): -+ self.config.runner.cib.load() -+ self.config.env.push_cib( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes"> -+ <nvpair id="{self.tag}-meta_attributes-a" name="a" value="A"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ ) -+ self.command(self.env_assist.get_env(), None, {"a": "A"}) -+ -+ def test_add_meta_if_missing(self): -+ self.config.runner.cib.load(optional_in_conf=f"<{self.tag} />") -+ self.config.env.push_cib( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-meta_attributes"> -+ <nvpair id="{self.tag}-meta_attributes-a" name="a" value="A"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ ) -+ self.command(self.env_assist.get_env(), None, {"a": "A"}) -+ -+ def test_dont_add_section_if_only_removing(self): -+ self.config.runner.cib.load() -+ self.command(self.env_assist.get_env(), None, {"a": "", "b": ""}) -+ -+ def test_dont_add_meta_if_only_removing(self): -+ self.config.runner.cib.load(optional_in_conf=f"<{self.tag} />") -+ self.command(self.env_assist.get_env(), None, {"a": "", "b": ""}) -+ -+ def test_keep_section_when_empty(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.config.env.push_cib(remove=f"./configuration/{self.tag}//nvpair") -+ self.command(self.env_assist.get_env(), None, {"a": "", "b": ""}) -+ -+ def test_ambiguous(self): -+ self.config.runner.cib.load( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-options"> -+ <nvpair id="{self.tag}-options-a" name="a" value="b"/> -+ <nvpair id="{self.tag}-options-b" name="b" value="c"/> -+ </meta_attributes> -+ <meta_attributes id="{self.tag}-options-1"> -+ <nvpair id="{self.tag}-options-c" name="c" value="d"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ ) -+ self.env_assist.assert_raise_library_error( -+ lambda: self.command(self.env_assist.get_env(), None, {"x": "y"}) -+ ) -+ self.reports = [ -+ fixture.error( -+ reports.codes.CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID, -+ pcs_command=self.command_for_report, -+ ) -+ ] -+ -+ -+class DefaultsUpdateMixin: -+ command = lambda *args, **kwargs: None -+ tag = "" -+ -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.env_assist, self.config = get_env_tools(self) -+ -+ def fixture_initial_defaults(self): -+ return f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-options"> -+ <nvpair id="{self.tag}-options-a" name="a" value="b"/> -+ <nvpair id="{self.tag}-options-b" name="b" value="c"/> -+ <nvpair id="{self.tag}-options-c" name="c" value="d"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ -+ def test_success(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.config.env.push_cib( -+ optional_in_conf=f""" -+ <{self.tag}> -+ <meta_attributes id="{self.tag}-options"> -+ <nvpair id="{self.tag}-options-a" name="a" value="B"/> -+ <nvpair id="{self.tag}-options-b" name="b" value="c"/> -+ <nvpair id="{self.tag}-options-d" name="d" value="e"/> -+ </meta_attributes> -+ </{self.tag}> -+ """ -+ ) -+ self.command( -+ self.env_assist.get_env(), -+ f"{self.tag}-options", -+ {"a": "B", "c": "", "d": "e"}, -+ ) -+ self.env_assist.assert_reports( -+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)] -+ ) -+ -+ def test_nvset_doesnt_exist(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.env_assist.assert_raise_library_error( -+ lambda: self.command( -+ self.env_assist.get_env(), "wrong-nvset-id", {}, -+ ) -+ ) -+ self.env_assist.assert_reports( -+ [ -+ fixture.report_not_found( -+ "wrong-nvset-id", -+ context_type=self.tag, -+ expected_types=["options set"], -+ ), -+ ] -+ ) -+ -+ def test_keep_elements_when_empty(self): -+ self.config.runner.cib.load( -+ optional_in_conf=self.fixture_initial_defaults() -+ ) -+ self.config.env.push_cib(remove=f"./configuration/{self.tag}//nvpair") -+ self.command( -+ self.env_assist.get_env(), -+ f"{self.tag}-options", -+ {"a": "", "b": "", "c": ""}, -+ ) -+ self.env_assist.assert_reports( -+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)] -+ ) -+ -+ -+class ResourceDefaultsUpdateLegacy(DefaultsUpdateLegacyMixin, TestCase): -+ command = staticmethod(cib_options.resource_defaults_update) -+ tag = "rsc_defaults" -+ command_for_report = reports.const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE -+ -+ -+class OperationDefaultsUpdateLegacy(DefaultsUpdateLegacyMixin, TestCase): -+ command = staticmethod(cib_options.operation_defaults_update) -+ tag = "op_defaults" -+ command_for_report = reports.const.PCS_COMMAND_OPERATION_DEFAULTS_UPDATE -+ -+ -+class ResourceDefaultsUpdate(DefaultsUpdateMixin, TestCase): -+ command = staticmethod(cib_options.resource_defaults_update) -+ tag = "rsc_defaults" -+ -+ -+class OperationDefaultsUpdate(DefaultsUpdateMixin, TestCase): -+ command = staticmethod(cib_options.operation_defaults_update) -+ tag = "op_defaults" -diff --git a/pcs_test/tier0/lib/test_validate.py b/pcs_test/tier0/lib/test_validate.py -index 002fd8ed..8c0e0261 100644 ---- a/pcs_test/tier0/lib/test_validate.py -+++ b/pcs_test/tier0/lib/test_validate.py -@@ -1238,6 +1238,33 @@ class ValuePositiveInteger(TestCase): - ) - - -+class ValueScore(TestCase): -+ def test_valid_score(self): -+ for score in [ -+ "1", -+ "-1", -+ "+1", -+ "123", -+ "-123", -+ "+123", -+ "INFINITY", -+ "-INFINITY", -+ "+INFINITY", -+ ]: -+ with self.subTest(score=score): -+ assert_report_item_list_equal( -+ validate.ValueScore("a").validate({"a": score}), [], -+ ) -+ -+ def test_not_valid_score(self): -+ for score in ["something", "++1", "--1", "++INFINITY"]: -+ with self.subTest(score=score): -+ assert_report_item_list_equal( -+ validate.ValueScore("a").validate({"a": score}), -+ [fixture.error(report_codes.INVALID_SCORE, score=score,),], -+ ) -+ -+ - class ValueTimeInterval(TestCase): - def test_no_reports_for_valid_time_interval(self): - for interval in ["0", "1s", "2sec", "3m", "4min", "5h", "6hr"]: -diff --git a/pcs_test/tier1/legacy/test_resource.py b/pcs_test/tier1/legacy/test_resource.py -index 5770d81a..7ffcc83b 100644 ---- a/pcs_test/tier1/legacy/test_resource.py -+++ b/pcs_test/tier1/legacy/test_resource.py -@@ -1421,9 +1421,9 @@ monitor interval=20 (A-monitor-interval-20) - No alerts defined - - Resources Defaults: -- No defaults set -+ No defaults set - Operations Defaults: -- No defaults set -+ No defaults set - - Cluster Properties: - -@@ -1657,9 +1657,9 @@ monitor interval=20 (A-monitor-interval-20) - No alerts defined - - Resources Defaults: -- No defaults set -+ No defaults set - Operations Defaults: -- No defaults set -+ No defaults set - - Cluster Properties: - -diff --git a/pcs_test/tier1/legacy/test_stonith.py b/pcs_test/tier1/legacy/test_stonith.py -index 3fc2c4d5..c51a02b5 100644 ---- a/pcs_test/tier1/legacy/test_stonith.py -+++ b/pcs_test/tier1/legacy/test_stonith.py -@@ -293,9 +293,9 @@ class StonithTest(TestCase, AssertPcsMixin): - No alerts defined - - Resources Defaults: -- No defaults set -+ No defaults set - Operations Defaults: -- No defaults set -+ No defaults set - - Cluster Properties: - -@@ -1305,9 +1305,9 @@ class LevelConfig(LevelTestsBase): - No alerts defined - - Resources Defaults: -- No defaults set -+ No defaults set - Operations Defaults: -- No defaults set -+ No defaults set - - Cluster Properties: - -diff --git a/pcs_test/tier1/test_cib_options.py b/pcs_test/tier1/test_cib_options.py -new file mode 100644 -index 00000000..ba8f3515 ---- /dev/null -+++ b/pcs_test/tier1/test_cib_options.py -@@ -0,0 +1,571 @@ -+from textwrap import dedent -+from unittest import TestCase -+ -+from lxml import etree -+ -+from pcs_test.tools.assertions import AssertPcsMixin -+from pcs_test.tools.cib import get_assert_pcs_effect_mixin -+from pcs_test.tools.misc import ( -+ get_test_resource as rc, -+ get_tmp_file, -+ skip_unless_pacemaker_supports_rsc_and_op_rules, -+ write_data_to_tmpfile, -+ write_file_to_tmpfile, -+) -+from pcs_test.tools.pcs_runner import PcsRunner -+from pcs_test.tools.xml import XmlManipulation -+ -+ -+empty_cib = rc("cib-empty-2.0.xml") -+empty_cib_rules = rc("cib-empty-3.4.xml") -+ -+ -+class TestDefaultsMixin: -+ def setUp(self): -+ # pylint: disable=invalid-name -+ self.temp_cib = get_tmp_file("tier1_cib_options") -+ self.pcs_runner = PcsRunner(self.temp_cib.name) -+ -+ def tearDown(self): -+ # pylint: disable=invalid-name -+ self.temp_cib.close() -+ -+ -+class DefaultsConfigMixin(TestDefaultsMixin, AssertPcsMixin): -+ cli_command = "" -+ prefix = "" -+ -+ def test_success(self): -+ xml_rsc = """ -+ <rsc_defaults> -+ <meta_attributes id="rsc-set1" score="10"> -+ <nvpair id="rsc-set1-nv1" name="name1" value="rsc1"/> -+ <nvpair id="rsc-set1-nv2" name="name2" value="rsc2"/> -+ </meta_attributes> -+ <meta_attributes id="rsc-setA"> -+ <nvpair id="rsc-setA-nv1" name="name1" value="rscA"/> -+ <nvpair id="rsc-setA-nv2" name="name2" value="rscB"/> -+ </meta_attributes> -+ </rsc_defaults> -+ """ -+ xml_op = """ -+ <op_defaults> -+ <meta_attributes id="op-set1" score="10"> -+ <nvpair id="op-set1-nv1" name="name1" value="op1"/> -+ <nvpair id="op-set1-nv2" name="name2" value="op2"/> -+ </meta_attributes> -+ <meta_attributes id="op-setA"> -+ <nvpair id="op-setA-nv1" name="name1" value="opA"/> -+ <nvpair id="op-setA-nv2" name="name2" value="opB"/> -+ </meta_attributes> -+ </op_defaults> -+ """ -+ xml_manip = XmlManipulation.from_file(empty_cib) -+ xml_manip.append_to_first_tag_name("configuration", xml_rsc, xml_op) -+ write_data_to_tmpfile(str(xml_manip), self.temp_cib) -+ -+ self.assert_pcs_success( -+ self.cli_command, -+ stdout_full=dedent( -+ f"""\ -+ Meta Attrs: {self.prefix}-set1 score=10 -+ name1={self.prefix}1 -+ name2={self.prefix}2 -+ Meta Attrs: {self.prefix}-setA -+ name1={self.prefix}A -+ name2={self.prefix}B -+ """ -+ ), -+ ) -+ -+ -+class RscDefaultsConfig( -+ DefaultsConfigMixin, TestCase, -+): -+ cli_command = "resource defaults" -+ prefix = "rsc" -+ -+ @skip_unless_pacemaker_supports_rsc_and_op_rules() -+ def test_success_rules(self): -+ xml = """ -+ <rsc_defaults> -+ <meta_attributes id="X"> -+ <rule id="X-rule" boolean-op="and" score="INFINITY"> -+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/> -+ </rule> -+ <nvpair id="X-nam1" name="nam1" value="val1"/> -+ </meta_attributes> -+ </rsc_defaults> -+ """ -+ xml_manip = XmlManipulation.from_file(empty_cib_rules) -+ xml_manip.append_to_first_tag_name("configuration", xml) -+ write_data_to_tmpfile(str(xml_manip), self.temp_cib) -+ -+ self.assert_pcs_success( -+ self.cli_command, -+ stdout_full=dedent( -+ """\ -+ Meta Attrs: X -+ nam1=val1 -+ Rule: boolean-op=and score=INFINITY -+ Expression: resource ::Dummy -+ """ -+ ), -+ ) -+ -+ -+class OpDefaultsConfig( -+ DefaultsConfigMixin, TestCase, -+): -+ cli_command = "resource op defaults" -+ prefix = "op" -+ -+ @skip_unless_pacemaker_supports_rsc_and_op_rules() -+ def test_success_rules(self): -+ xml = """ -+ <op_defaults> -+ <meta_attributes id="X"> -+ <rule id="X-rule" boolean-op="and" score="INFINITY"> -+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/> -+ <op_expression id="X-rule-op-monitor" name="monitor"/> -+ </rule> -+ <nvpair id="X-nam1" name="nam1" value="val1"/> -+ </meta_attributes> -+ </op_defaults> -+ """ -+ xml_manip = XmlManipulation.from_file(empty_cib_rules) -+ xml_manip.append_to_first_tag_name("configuration", xml) -+ write_data_to_tmpfile(str(xml_manip), self.temp_cib) -+ -+ self.assert_pcs_success( -+ self.cli_command, -+ stdout_full=dedent( -+ """\ -+ Meta Attrs: X -+ nam1=val1 -+ Rule: boolean-op=and score=INFINITY -+ Expression: resource ::Dummy -+ Expression: op monitor -+ """ -+ ), -+ ) -+ -+ -+class DefaultsSetCreateMixin(TestDefaultsMixin): -+ cli_command = "" -+ cib_tag = "" -+ -+ def setUp(self): -+ super().setUp() -+ write_file_to_tmpfile(empty_cib, self.temp_cib) -+ -+ def test_no_args(self): -+ self.assert_effect( -+ f"{self.cli_command} set create", -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="{self.cib_tag}-meta_attributes"/> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=( -+ "Warning: Defaults do not apply to resources which override " -+ "them with their own defined values\n" -+ ), -+ ) -+ -+ def test_success(self): -+ self.assert_effect( -+ ( -+ f"{self.cli_command} set create id=mine score=10 " -+ "meta nam1=val1 nam2=val2 --force" -+ ), -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="mine" score="10"> -+ <nvpair id="mine-nam1" name="nam1" value="val1"/> -+ <nvpair id="mine-nam2" name="nam2" value="val2"/> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=( -+ "Warning: Defaults do not apply to resources which override " -+ "them with their own defined values\n" -+ ), -+ ) -+ -+ -+class RscDefaultsSetCreate( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//rsc_defaults")[0] -+ ) -+ ), -+ DefaultsSetCreateMixin, -+ TestCase, -+): -+ cli_command = "resource defaults" -+ cib_tag = "rsc_defaults" -+ -+ @skip_unless_pacemaker_supports_rsc_and_op_rules() -+ def test_success_rules(self): -+ self.assert_effect( -+ ( -+ f"{self.cli_command} set create id=X meta nam1=val1 " -+ "rule resource ::Dummy" -+ ), -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="X"> -+ <rule id="X-rule" boolean-op="and" score="INFINITY"> -+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/> -+ </rule> -+ <nvpair id="X-nam1" name="nam1" value="val1"/> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """, -+ output=( -+ "CIB has been upgraded to the latest schema version.\n" -+ "Warning: Defaults do not apply to resources which override " -+ "them with their own defined values\n" -+ ), -+ ) -+ -+ -+class OpDefaultsSetCreate( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//op_defaults")[0] -+ ) -+ ), -+ DefaultsSetCreateMixin, -+ TestCase, -+): -+ cli_command = "resource op defaults" -+ cib_tag = "op_defaults" -+ -+ @skip_unless_pacemaker_supports_rsc_and_op_rules() -+ def test_success_rules(self): -+ self.assert_effect( -+ ( -+ f"{self.cli_command} set create id=X meta nam1=val1 " -+ "rule resource ::Dummy and op monitor" -+ ), -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="X"> -+ <rule id="X-rule" boolean-op="and" score="INFINITY"> -+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/> -+ <op_expression id="X-rule-op-monitor" name="monitor"/> -+ </rule> -+ <nvpair id="X-nam1" name="nam1" value="val1"/> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """, -+ output=( -+ "CIB has been upgraded to the latest schema version.\n" -+ "Warning: Defaults do not apply to resources which override " -+ "them with their own defined values\n" -+ ), -+ ) -+ -+ -+class DefaultsSetDeleteMixin(TestDefaultsMixin, AssertPcsMixin): -+ cli_command = "" -+ prefix = "" -+ cib_tag = "" -+ -+ def setUp(self): -+ super().setUp() -+ xml_rsc = """ -+ <rsc_defaults> -+ <meta_attributes id="rsc-set1" /> -+ <meta_attributes id="rsc-set2" /> -+ <meta_attributes id="rsc-set3" /> -+ <meta_attributes id="rsc-set4" /> -+ </rsc_defaults> -+ """ -+ xml_op = """ -+ <op_defaults> -+ <meta_attributes id="op-set1" /> -+ <meta_attributes id="op-set2" /> -+ <meta_attributes id="op-set3" /> -+ <meta_attributes id="op-set4" /> -+ </op_defaults> -+ """ -+ xml_manip = XmlManipulation.from_file(empty_cib) -+ xml_manip.append_to_first_tag_name("configuration", xml_rsc, xml_op) -+ write_data_to_tmpfile(str(xml_manip), self.temp_cib) -+ -+ def test_success(self): -+ self.assert_effect( -+ [ -+ f"{self.cli_command} set delete {self.prefix}-set1 " -+ f"{self.prefix}-set3", -+ f"{self.cli_command} set remove {self.prefix}-set1 " -+ f"{self.prefix}-set3", -+ ], -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="{self.prefix}-set2" /> -+ <meta_attributes id="{self.prefix}-set4" /> -+ </{self.cib_tag}> -+ """ -+ ), -+ ) -+ -+ -+class RscDefaultsSetDelete( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//rsc_defaults")[0] -+ ) -+ ), -+ DefaultsSetDeleteMixin, -+ TestCase, -+): -+ cli_command = "resource defaults" -+ prefix = "rsc" -+ cib_tag = "rsc_defaults" -+ -+ -+class OpDefaultsSetDelete( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//op_defaults")[0] -+ ) -+ ), -+ DefaultsSetDeleteMixin, -+ TestCase, -+): -+ cli_command = "resource op defaults" -+ prefix = "op" -+ cib_tag = "op_defaults" -+ -+ -+class DefaultsSetUpdateMixin(TestDefaultsMixin, AssertPcsMixin): -+ cli_command = "" -+ prefix = "" -+ cib_tag = "" -+ -+ def test_success(self): -+ xml = f""" -+ <{self.cib_tag}> -+ <meta_attributes id="my-set"> -+ <nvpair id="my-set-name1" name="name1" value="value1" /> -+ <nvpair id="my-set-name2" name="name2" value="value2" /> -+ <nvpair id="my-set-name3" name="name3" value="value3" /> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """ -+ xml_manip = XmlManipulation.from_file(empty_cib) -+ xml_manip.append_to_first_tag_name("configuration", xml) -+ write_data_to_tmpfile(str(xml_manip), self.temp_cib) -+ warnings = ( -+ "Warning: Defaults do not apply to resources which override " -+ "them with their own defined values\n" -+ ) -+ -+ self.assert_effect( -+ f"{self.cli_command} set update my-set meta name2=value2A name3=", -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="my-set"> -+ <nvpair id="my-set-name1" name="name1" value="value1" /> -+ <nvpair id="my-set-name2" name="name2" value="value2A" /> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=warnings, -+ ) -+ -+ self.assert_effect( -+ f"{self.cli_command} set update my-set meta name1= name2=", -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="my-set" /> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=warnings, -+ ) -+ -+ -+class RscDefaultsSetUpdate( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//rsc_defaults")[0] -+ ) -+ ), -+ DefaultsSetUpdateMixin, -+ TestCase, -+): -+ cli_command = "resource defaults" -+ prefix = "rsc" -+ cib_tag = "rsc_defaults" -+ -+ -+class OpDefaultsSetUpdate( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//op_defaults")[0] -+ ) -+ ), -+ DefaultsSetUpdateMixin, -+ TestCase, -+): -+ cli_command = "resource op defaults" -+ prefix = "op" -+ cib_tag = "op_defaults" -+ -+ -+class DefaultsSetUsageMixin(TestDefaultsMixin, AssertPcsMixin): -+ cli_command = "" -+ -+ def test_no_args(self): -+ self.assert_pcs_fail( -+ f"{self.cli_command} set", -+ stdout_start=f"\nUsage: pcs {self.cli_command} set...\n", -+ ) -+ -+ def test_bad_command(self): -+ self.assert_pcs_fail( -+ f"{self.cli_command} set bad-command", -+ stdout_start=f"\nUsage: pcs {self.cli_command} set ...\n", -+ ) -+ -+ -+class RscDefaultsSetUsage( -+ DefaultsSetUsageMixin, TestCase, -+): -+ cli_command = "resource defaults" -+ -+ -+class OpDefaultsSetUsage( -+ DefaultsSetUsageMixin, TestCase, -+): -+ cli_command = "resource op defaults" -+ -+ -+class DefaultsUpdateMixin(TestDefaultsMixin, AssertPcsMixin): -+ cli_command = "" -+ prefix = "" -+ cib_tag = "" -+ -+ def assert_success_legacy(self, update_keyword): -+ write_file_to_tmpfile(empty_cib, self.temp_cib) -+ warning_lines = [] -+ if not update_keyword: -+ warning_lines.append( -+ "Warning: This command is deprecated and will be removed. " -+ f"Please use 'pcs {self.cli_command} update' instead.\n" -+ ) -+ warning_lines.append( -+ "Warning: Defaults do not apply to resources which override " -+ "them with their own defined values\n" -+ ) -+ warnings = "".join(warning_lines) -+ -+ update = "update" if update_keyword else "" -+ -+ self.assert_effect( -+ f"{self.cli_command} {update} name1=value1 name2=value2 name3=value3", -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="{self.cib_tag}-meta_attributes"> -+ <nvpair id="{self.cib_tag}-meta_attributes-name1" -+ name="name1" value="value1" -+ /> -+ <nvpair id="{self.cib_tag}-meta_attributes-name2" -+ name="name2" value="value2" -+ /> -+ <nvpair id="{self.cib_tag}-meta_attributes-name3" -+ name="name3" value="value3" -+ /> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=warnings, -+ ) -+ -+ self.assert_effect( -+ f"{self.cli_command} {update} name2=value2A name3=", -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="{self.cib_tag}-meta_attributes"> -+ <nvpair id="{self.cib_tag}-meta_attributes-name1" -+ name="name1" value="value1" -+ /> -+ <nvpair id="{self.cib_tag}-meta_attributes-name2" -+ name="name2" value="value2A" -+ /> -+ </meta_attributes> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=warnings, -+ ) -+ -+ self.assert_effect( -+ f"{self.cli_command} {update} name1= name2=", -+ dedent( -+ f"""\ -+ <{self.cib_tag}> -+ <meta_attributes id="{self.cib_tag}-meta_attributes" /> -+ </{self.cib_tag}> -+ """ -+ ), -+ output=warnings, -+ ) -+ -+ def test_deprecated(self): -+ self.assert_success_legacy(False) -+ -+ def test_legacy(self): -+ self.assert_success_legacy(True) -+ -+ -+class RscDefaultsUpdate( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//rsc_defaults")[0] -+ ) -+ ), -+ DefaultsUpdateMixin, -+ TestCase, -+): -+ cli_command = "resource defaults" -+ prefix = "rsc" -+ cib_tag = "rsc_defaults" -+ -+ -+class OpDefaultsUpdate( -+ get_assert_pcs_effect_mixin( -+ lambda cib: etree.tostring( -+ # pylint:disable=undefined-variable -+ etree.parse(cib).findall(".//op_defaults")[0] -+ ) -+ ), -+ DefaultsUpdateMixin, -+ TestCase, -+): -+ cli_command = "resource op defaults" -+ prefix = "op" -+ cib_tag = "op_defaults" -diff --git a/pcs_test/tier1/test_tag.py b/pcs_test/tier1/test_tag.py -index d28d3ae5..8057476a 100644 ---- a/pcs_test/tier1/test_tag.py -+++ b/pcs_test/tier1/test_tag.py -@@ -246,9 +246,9 @@ class PcsConfigTagsTest(TestTagMixin, TestCase): - No alerts defined - - Resources Defaults: -- No defaults set -+ No defaults set - Operations Defaults: -- No defaults set -+ No defaults set - - Cluster Properties: - {tags} -diff --git a/pcs_test/tools/fixture.py b/pcs_test/tools/fixture.py -index a460acc7..6480617e 100644 ---- a/pcs_test/tools/fixture.py -+++ b/pcs_test/tools/fixture.py -@@ -245,14 +245,14 @@ def report_resource_running(resource, roles, severity=severities.INFO): - ) - - --def report_unexpected_element(element_id, elemet_type, expected_types): -+def report_unexpected_element(element_id, element_type, expected_types): - return ( - severities.ERROR, - report_codes.ID_BELONGS_TO_UNEXPECTED_TYPE, - { - "id": element_id, - "expected_types": expected_types, -- "current_type": elemet_type, -+ "current_type": element_type, - }, - None, - ) -diff --git a/pcs_test/tools/misc.py b/pcs_test/tools/misc.py -index f481a267..33d78002 100644 ---- a/pcs_test/tools/misc.py -+++ b/pcs_test/tools/misc.py -@@ -5,6 +5,8 @@ import re - import tempfile - from unittest import mock, skipUnless - -+from lxml import etree -+ - from pcs_test.tools.custom_mock import MockLibraryReportProcessor - - from pcs import settings -@@ -128,12 +130,12 @@ def compare_version(a, b): - - def is_minimum_pacemaker_version(major, minor, rev): - return is_version_sufficient( -- get_current_pacemaker_version(), (major, minor, rev) -+ _get_current_pacemaker_version(), (major, minor, rev) - ) - - - @lru_cache() --def get_current_pacemaker_version(): -+def _get_current_pacemaker_version(): - output, dummy_stderr, dummy_retval = runner.run( - [os.path.join(settings.pacemaker_binaries, "crm_mon"), "--version",] - ) -@@ -146,6 +148,26 @@ def get_current_pacemaker_version(): - return major, minor, rev - - -+@lru_cache() -+def _get_current_cib_schema_version(): -+ regexp = re.compile(r"pacemaker-((\d+)\.(\d+))") -+ all_versions = set() -+ xml = etree.parse("/usr/share/pacemaker/versions.rng").getroot() -+ for value_el in xml.xpath( -+ ".//x:attribute[@name='validate-with']//x:value", -+ namespaces={"x": "http://relaxng.org/ns/structure/1.0"}, -+ ): -+ match = re.match(regexp, value_el.text) -+ if match: -+ all_versions.add((int(match.group(2)), int(match.group(3)))) -+ return sorted(all_versions)[-1] -+ -+ -+def _is_minimum_cib_schema_version(cmajor, cminor, crev): -+ major, minor = _get_current_cib_schema_version() -+ return compare_version((major, minor, 0), (cmajor, cminor, crev)) > -1 -+ -+ - def is_version_sufficient(current_version, minimal_version): - return compare_version(current_version, minimal_version) > -1 - -@@ -174,7 +196,7 @@ def _get_current_pacemaker_features(): - - - def skip_unless_pacemaker_version(version_tuple, feature): -- current_version = get_current_pacemaker_version() -+ current_version = _get_current_pacemaker_version() - return skipUnless( - is_version_sufficient(current_version, version_tuple), - ( -@@ -188,12 +210,6 @@ def skip_unless_pacemaker_version(version_tuple, feature): - ) - - --def skip_unless_crm_rule(): -- return skip_unless_pacemaker_version( -- (2, 0, 2), "listing of constraints that might be expired" -- ) -- -- - def skip_unless_pacemaker_features(version_tuple, feature): - return skipUnless( - is_minimum_pacemaker_features(*version_tuple), -@@ -204,12 +220,39 @@ def skip_unless_pacemaker_features(version_tuple, feature): - ) - - -+def skip_unless_cib_schema_version(version_tuple, feature): -+ current_version = _get_current_cib_schema_version() -+ return skipUnless( -+ _is_minimum_cib_schema_version(*version_tuple), -+ ( -+ "Pacemaker supported CIB schema version is too low (current: " -+ "{current_version}, must be >= {minimal_version}) to test {feature}" -+ ).format( -+ current_version=format_version(current_version), -+ minimal_version=format_version(version_tuple), -+ feature=feature, -+ ), -+ ) -+ -+ -+def skip_unless_crm_rule(): -+ return skip_unless_pacemaker_version( -+ (2, 0, 2), "listing of constraints that might be expired" -+ ) -+ -+ - def skip_unless_pacemaker_supports_bundle(): - return skip_unless_pacemaker_features( - (3, 1, 0), "bundle resources with promoted-max attribute" - ) - - -+def skip_unless_pacemaker_supports_rsc_and_op_rules(): -+ return skip_unless_cib_schema_version( -+ (3, 4, 0), "rsc_expression and op_expression elements in rule elements" -+ ) -+ -+ - def skip_if_service_enabled(service_name): - return skipUnless( - not is_service_enabled(runner, service_name), -diff --git a/pcsd/capabilities.xml b/pcsd/capabilities.xml -index daf23e5a..6e1886cb 100644 ---- a/pcsd/capabilities.xml -+++ b/pcsd/capabilities.xml -@@ -964,6 +964,21 @@ - pcs commands: resource op defaults - </description> - </capability> -+ <capability id="pcmk.properties.operation-defaults.multiple" in-pcs="1" in-pcsd="0"> -+ <description> -+ Support for managing multiple sets of resource operations defaults. -+ -+ pcs commands: resource op defaults set create | delete | remove | update -+ </description> -+ </capability> -+ <capability id="pcmk.properties.operation-defaults.rule-rsc-op" in-pcs="1" in-pcsd="0"> -+ <description> -+ Support for rules with 'resource' and 'op' expressions in sets of -+ resource operations defaults. -+ -+ pcs commands: resource op defaults set create -+ </description> -+ </capability> - <capability id="pcmk.properties.resource-defaults" in-pcs="1" in-pcsd="0"> - <description> - Show and set resources defaults, can set multiple defaults at once. -@@ -971,6 +986,21 @@ - pcs commands: resource defaults - </description> - </capability> -+ <capability id="pcmk.properties.resource-defaults.multiple" in-pcs="1" in-pcsd="0"> -+ <description> -+ Support for managing multiple sets of resources defaults. -+ -+ pcs commands: resource defaults set create | delete | remove | update -+ </description> -+ </capability> -+ <capability id="pcmk.properties.resource-defaults.rule-rsc-op" in-pcs="1" in-pcsd="0"> -+ <description> -+ Support for rules with 'resource' and 'op' expressions in sets of -+ resources defaults. -+ -+ pcs commands: resource defaults set create -+ </description> -+ </capability> - - - -diff --git a/test/centos8/Dockerfile b/test/centos8/Dockerfile -index bcdfadef..753f0ca7 100644 ---- a/test/centos8/Dockerfile -+++ b/test/centos8/Dockerfile -@@ -12,6 +12,7 @@ RUN dnf install -y \ - python3-pip \ - python3-pycurl \ - python3-pyOpenSSL \ -+ python3-pyparsing \ - # ruby - ruby \ - ruby-devel \ -diff --git a/test/fedora30/Dockerfile b/test/fedora30/Dockerfile -index 60aad892..7edbfe5b 100644 ---- a/test/fedora30/Dockerfile -+++ b/test/fedora30/Dockerfile -@@ -9,6 +9,7 @@ RUN dnf install -y \ - python3-mock \ - python3-pycurl \ - python3-pyOpenSSL \ -+ python3-pyparsing \ - # ruby - ruby \ - ruby-devel \ -diff --git a/test/fedora31/Dockerfile b/test/fedora31/Dockerfile -index eb24bb1c..6750e222 100644 ---- a/test/fedora31/Dockerfile -+++ b/test/fedora31/Dockerfile -@@ -10,6 +10,7 @@ RUN dnf install -y \ - python3-pip \ - python3-pycurl \ - python3-pyOpenSSL \ -+ python3-pyparsing \ - # ruby - ruby \ - ruby-devel \ -diff --git a/test/fedora32/Dockerfile b/test/fedora32/Dockerfile -index 61a0a439..c6cc2146 100644 ---- a/test/fedora32/Dockerfile -+++ b/test/fedora32/Dockerfile -@@ -11,6 +11,7 @@ RUN dnf install -y \ - python3-pip \ - python3-pycurl \ - python3-pyOpenSSL \ -+ python3-pyparsing \ - # ruby - ruby \ - ruby-devel \ --- -2.25.4 - diff --git a/SOURCES/do-not-support-cluster-setup-with-udp-u-transport.patch b/SOURCES/do-not-support-cluster-setup-with-udp-u-transport.patch index 800145e..a0a7aab 100644 --- a/SOURCES/do-not-support-cluster-setup-with-udp-u-transport.patch +++ b/SOURCES/do-not-support-cluster-setup-with-udp-u-transport.patch @@ -1,7 +1,7 @@ -From c0fff964cc07e3a9fbdea85da33abe3329c653a3 Mon Sep 17 00:00:00 2001 +From ab9fd9f223e805247319ac5a7318c15417197a0a Mon Sep 17 00:00:00 2001 From: Ivan Devat <idevat@redhat.com> Date: Tue, 20 Nov 2018 15:03:56 +0100 -Subject: [PATCH 3/3] do not support cluster setup with udp(u) transport +Subject: [PATCH] do not support cluster setup with udp(u) transport --- pcs/pcs.8 | 2 ++ @@ -10,10 +10,10 @@ Subject: [PATCH 3/3] do not support cluster setup with udp(u) transport 3 files changed, 6 insertions(+) diff --git a/pcs/pcs.8 b/pcs/pcs.8 -index 3efc5bb2..20247774 100644 +index edfdd039..8caf087f 100644 --- a/pcs/pcs.8 +++ b/pcs/pcs.8 -@@ -376,6 +376,8 @@ By default, encryption is enabled with cipher=aes256 and hash=sha256. To disable +@@ -424,6 +424,8 @@ By default, encryption is enabled with cipher=aes256 and hash=sha256. To disable Transports udp and udpu: .br @@ -23,10 +23,10 @@ index 3efc5bb2..20247774 100644 .br Transport options are: ip_version, netmtu diff --git a/pcs/usage.py b/pcs/usage.py -index 0f3c95a3..51bc1196 100644 +index baedb347..f576eaf2 100644 --- a/pcs/usage.py +++ b/pcs/usage.py -@@ -796,6 +796,7 @@ Commands: +@@ -852,6 +852,7 @@ Commands: hash=sha256. To disable encryption, set cipher=none and hash=none. Transports udp and udpu: @@ -49,5 +49,5 @@ index b857cbae..b8d48d92 100644 #csetup-transport-options.knet .without-knet { -- -2.25.4 +2.26.2 diff --git a/SPECS/pcs.spec b/SPECS/pcs.spec index 3a207a6..9725456 100644 --- a/SPECS/pcs.spec +++ b/SPECS/pcs.spec @@ -1,17 +1,18 @@ Name: pcs -Version: 0.10.6 -Release: 2%{?dist} +Version: 0.10.8 +Release: 1%{?dist} # https://docs.fedoraproject.org/en-US/packaging-guidelines/LicensingGuidelines/ # https://fedoraproject.org/wiki/Licensing:Main?rd=Licensing#Good_Licenses # GPLv2: pcs # ASL 2.0: dataclasses, tornado -# MIT: handlebars, backports, dacite, daemons, ethon, mustermann, rack, -# rack-protection, rack-test, sinatra, tilt +# ASL 2.0 or BSD: dateutil +# MIT: backports, dacite, daemons, ember, ethon, handlebars, jquery, jquery-ui, +# mustermann, rack, rack-protection, rack-test, sinatra, tilt # GPLv2 or Ruby: eventmachne, json # (GPLv2 or Ruby) and BSD: thin # BSD or Ruby: open4, ruby2_keywords # BSD and MIT: ffi -License: GPLv2 and ASL 2.0 and MIT and BSD and (GPLv2 or Ruby) and (BSD or Ruby) +License: GPLv2 and ASL 2.0 and MIT and BSD and (GPLv2 or Ruby) and (BSD or Ruby) and (ASL 2.0 or BSD) URL: https://github.com/ClusterLabs/pcs Group: System Environment/Base Summary: Pacemaker Configuration System @@ -19,20 +20,22 @@ Summary: Pacemaker Configuration System ExclusiveArch: i686 x86_64 s390x ppc64le aarch64 %global version_or_commit %{version} -# %%global version_or_commit 5c3f35d2819b0e8be0dcbe0ee8f81b9b24b20b54 +# %%global version_or_commit 508b3999eb02b4901e83b8e780af8422b522ad30 %global pcs_source_name %{name}-%{version_or_commit} -# ui_commit can be determined by hash, tag or branch -%global ui_commit 0.1.3 +# ui_commit can be determined by hash, tag or branch +%global ui_commit 0.1.5 +%global ui_modules_version 0.1.5 %global ui_src_name pcs-web-ui-%{ui_commit} %global pcs_snmp_pkg_name pcs-snmp %global pyagentx_version 0.4.pcs.2 -%global tornado_version 6.0.4 -%global dataclasses_version 0.6 -%global dacite_version 1.5.0 +%global tornado_version 6.1.0 +%global dataclasses_version 0.8 +%global dacite_version 1.6.0 +%global dateutil_version 2.8.1 %global version_rubygem_backports 3.17.2 %global version_rubygem_daemons 1.3.1 %global version_rubygem_ethon 0.12.0 @@ -49,6 +52,12 @@ ExclusiveArch: i686 x86_64 s390x ppc64le aarch64 %global version_rubygem_thin 1.7.2 %global version_rubygem_tilt 2.0.10 +# javascript bundled libraries for old web-ui +%global ember_version 1.4.0 +%global handlebars_version 1.2.1 +%global jquery_ui_version 1.10.1 +%global jquery_version 1.9.1 + # We do not use _libdir macro because upstream is not prepared for it. # Pcs does not include binaries and thus it should live in /usr/lib. Tornado # and gems include binaries and thus it should live in /usr/lib64. But the @@ -84,6 +93,7 @@ Source41: https://github.com/ondrejmular/pyagentx/archive/v%{pyagentx_version}/p Source42: https://github.com/tornadoweb/tornado/archive/v%{tornado_version}/tornado-%{tornado_version}.tar.gz Source43: https://github.com/ericvsmith/dataclasses/archive/%{dataclasses_version}/dataclasses-%{dataclasses_version}.tar.gz Source44: https://github.com/konradhalas/dacite/archive/v%{dacite_version}/dacite-%{dacite_version}.tar.gz +Source45: https://github.com/dateutil/dateutil/archive/%{dateutil_version}/python-dateutil-%{dateutil_version}.tar.gz Source81: https://rubygems.org/downloads/backports-%{version_rubygem_backports}.gem Source82: https://rubygems.org/downloads/ethon-%{version_rubygem_ethon}.gem @@ -105,22 +115,20 @@ Source95: https://rubygems.org/downloads/thin-%{version_rubygem_thin}.gem Source96: https://rubygems.org/downloads/ruby2_keywords-%{version_rubygem_ruby2_keywords}.gem Source100: https://github.com/idevat/pcs-web-ui/archive/%{ui_commit}/%{ui_src_name}.tar.gz -Source101: https://github.com/idevat/pcs-web-ui/releases/download/%{ui_commit}/pcs-web-ui-node-modules-%{ui_commit}.tar.xz +Source101: https://github.com/idevat/pcs-web-ui/releases/download/%{ui_modules_version}/pcs-web-ui-node-modules-%{ui_modules_version}.tar.xz # Patches from upstream. # They should come before downstream patches to avoid unnecessary conflicts. # Z-streams are exception here: they can come from upstream but should be # applied at the end to keep z-stream changes as straightforward as possible. -# Patch1: name.patch -Patch1: bz1817547-01-resource-and-operation-defaults.patch -Patch2: bz1805082-01-fix-resource-stonith-refresh-documentation.patch +# Patch1: bzNUMBER-01-name.patch # Downstream patches do not come from upstream. They adapt pcs for specific # RHEL needs. Patch101: do-not-support-cluster-setup-with-udp-u-transport.patch # git for patches -BuildRequires: git +BuildRequires: git-core #printf from coreutils is used in makefile BuildRequires: coreutils # python for pcs @@ -129,6 +137,8 @@ BuildRequires: python3-devel BuildRequires: platform-python-setuptools BuildRequires: python3-pycurl BuildRequires: python3-pyparsing +# for bundled python dateutil +BuildRequires: python3-setuptools_scm # gcc for compiling custom rubygems BuildRequires: gcc BuildRequires: gcc-c++ @@ -148,6 +158,7 @@ BuildRequires: python3-pyOpenSSL # pcsd fonts and font management tools for creating symlinks to fonts BuildRequires: fontconfig BuildRequires: liberation-sans-fonts +BuildRequires: make BuildRequires: overpass-fonts # Red Hat logo for creating symlink of favicon BuildRequires: redhat-logos @@ -195,6 +206,7 @@ Requires: logrotate Provides: bundled(tornado) = %{tornado_version} Provides: bundled(dataclasses) = %{dataclasses_version} Provides: bundled(dacite) = %{dacite_version} +Provides: bundled(dateutil) = %{dateutil_version} Provides: bundled(backports) = %{version_rubygem_backports} Provides: bundled(daemons) = %{version_rubygem_daemons} Provides: bundled(ethon) = %{version_rubygem_ethon} @@ -211,6 +223,12 @@ Provides: bundled(sinatra) = %{version_rubygem_sinatra} Provides: bundled(thin) = %{version_rubygem_thin} Provides: bundled(tilt) = %{version_rubygem_tilt} +# javascript bundled libraries for old web-ui +Provides: bundled(ember) = %{ember_version} +Provides: bundled(handlebars) = %{handlebars_version} +Provides: bundled(jquery) = %{jquery_version} +Provides: bundled(jquery-ui) = %{jquery_ui_version} + %description pcs is a corosync and pacemaker configuration tool. It permits users to easily view, modify and create pacemaker based clusters. @@ -280,8 +298,6 @@ update_times_patch(){ } # update_times_patch %%{PATCH1} -update_times_patch %{PATCH1} -update_times_patch %{PATCH2} update_times_patch %{PATCH101} cp -f %SOURCE1 pcsd/public/images @@ -349,6 +365,13 @@ update_times %SOURCE44 `find %{bundled_src_dir}/dacite -follow` cp %{bundled_src_dir}/dacite/LICENSE dacite_LICENSE cp %{bundled_src_dir}/dacite/README.md dacite_README.md +# 8) sources for python dateutil +tar -xzf %SOURCE45 -C %{bundled_src_dir} +mv %{bundled_src_dir}/python-dateutil-%{dateutil_version} %{bundled_src_dir}/python-dateutil +update_times %SOURCE45 `find %{bundled_src_dir}/python-dateutil -follow` +cp %{bundled_src_dir}/python-dateutil/LICENSE dateutil_LICENSE +cp %{bundled_src_dir}/python-dateutil/README.rst dateutil_README.rst + %build %define debug_package %{nil} @@ -358,6 +381,11 @@ pwd # build bundled rubygems (in main install it is disabled by BUILD_GEMS=false) mkdir -p %{rubygem_bundle_dir} +# The '-g' cflags option is needed for generation of MiniDebugInfo for shared +# libraries from rubygem extensions +# Currently used rubygems with extensions: eventmachine, ffi, json, thin +# There was rpmdiff issue with missing .gnu_debugdata section +# see https://docs.engineering.redhat.com/display/HTD/rpmdiff-elf-stripping gem install \ --force --verbose --no-rdoc --no-ri -l --no-user-install \ -i %{rubygem_bundle_dir} \ @@ -377,7 +405,7 @@ gem install \ %{rubygem_cache_dir}/thin-%{version_rubygem_thin}.gem \ %{rubygem_cache_dir}/tilt-%{version_rubygem_tilt}.gem \ -- '--with-ldflags="-Wl,-z,relro -Wl,-z,ibt -Wl,-z,now -Wl,--gc-sections"' \ - '--with-cflags="-O2 -ffunction-sections"' + '--with-cflags="-g -O2 -ffunction-sections"' # prepare license files # some rubygems do not have a license file (ruby2_keywords, thin) @@ -398,19 +426,6 @@ mv %{rubygem_bundle_dir}/gems/rack-test-%{version_rubygem_rack_test}/MIT-LICENSE mv %{rubygem_bundle_dir}/gems/sinatra-%{version_rubygem_sinatra}/LICENSE sinatra_LICENSE mv %{rubygem_bundle_dir}/gems/tilt-%{version_rubygem_tilt}/COPYING tilt_COPYING -# We can remove files required for gem compilation -rm -rf %{rubygem_bundle_dir}/gems/eventmachine-%{version_rubygem_eventmachine}/ext -rm -rf %{rubygem_bundle_dir}/gems/ffi-%{version_rubygem_ffi}/ext -rm -rf %{rubygem_bundle_dir}/gems/json-%{version_rubygem_json}/ext -rm -rf %{rubygem_bundle_dir}/gems/thin-%{version_rubygem_thin}/ext - - -# With this file there is "File is not stripped" problem during rpmdiff -# See https://docs.engineering.redhat.com/display/HTD/rpmdiff-elf-stripping -for fname in `find %{rubygem_bundle_dir}/extensions -type f -name "*.so"`; do - strip ${fname} -done - # build web ui and put it to pcsd make -C %{pcsd_public_dir}/%{ui_src_name} build mv %{pcsd_public_dir}/%{ui_src_name}/build pcsd/public/ui @@ -428,6 +443,7 @@ make install \ BUNDLE_PYAGENTX_SRC_DIR=`readlink -f %{bundled_src_dir}/pyagentx` \ BUNDLE_TORNADO_SRC_DIR=`readlink -f %{bundled_src_dir}/tornado` \ BUNDLE_DACITE_SRC_DIR=`readlink -f %{bundled_src_dir}/dacite` \ + BUNDLE_DATEUTIL_SRC_DIR=`readlink -f %{bundled_src_dir}/python-dateutil` \ BUNDLE_DATACLASSES_SRC_DIR=`readlink -f %{bundled_src_dir}/dataclasses` \ BUILD_GEMS=false \ SYSTEMCTL_OVERRIDE=true \ @@ -435,18 +451,29 @@ make install \ rubyhdrdir="%{_includedir}" \ includedir="%{_includedir}" -# With this file there is "File is not stripped" problem during rpmdiff -# See https://docs.engineering.redhat.com/display/HTD/rpmdiff-elf-stripping -for fname in `find ${RPM_BUILD_ROOT}%{pcs_libdir}/pcs/bundled/packages/tornado/ -type f -name "*.so"`; do - strip ${fname} -done - # symlink favicon into pcsd directories ln -fs /etc/favicon.png ${RPM_BUILD_ROOT}%{pcs_libdir}/%{pcsd_public_dir}/images/favicon.png #after the ruby gem compilation we do not need ruby gems in the cache rm -r -v $RPM_BUILD_ROOT%{pcs_libdir}/%{rubygem_cache_dir} +# We are not building debug package for pcs but we need to add MiniDebuginfo +# to the bundled shared libraries from rubygem extensions in order to satisfy +# rpmdiff's binary stripping checker. +# Therefore we call find-debuginfo.sh script manually in order to strip +# binaries and add MiniDebugInfo with .gnu_debugdata section +/usr/lib/rpm/find-debuginfo.sh -j2 -m -i -S debugsourcefiles.list +# find-debuginfo.sh generated some files into /usr/lib/debug and +# /usr/src/debug/ that we don't want in the package +rm -rf $RPM_BUILD_ROOT%{pcs_libdir}/debug +rm -rf $RPM_BUILD_ROOT%{_prefix}/src/debug + +# We can remove files required for gem compilation +rm -rf $RPM_BUILD_ROOT%{pcs_libdir}/%{rubygem_bundle_dir}/gems/eventmachine-%{version_rubygem_eventmachine}/ext +rm -rf $RPM_BUILD_ROOT%{pcs_libdir}/%{rubygem_bundle_dir}/gems/ffi-%{version_rubygem_ffi}/ext +rm -rf $RPM_BUILD_ROOT%{pcs_libdir}/%{rubygem_bundle_dir}/gems/json-%{version_rubygem_json}/ext +rm -rf $RPM_BUILD_ROOT%{pcs_libdir}/%{rubygem_bundle_dir}/gems/thin-%{version_rubygem_thin}/ext + %check # In the building environment LC_CTYPE is set to C which causes tests to fail # due to python prints a warning about it to stderr. The following environment @@ -536,9 +563,11 @@ remove_all_tests %doc README.md %doc tornado_README.rst %doc dacite_README.md +%doc dateutil_README.rst %doc dataclasses_README.rst %license tornado_LICENSE %license dacite_LICENSE +%license dateutil_LICENSE %license dataclasses_LICENSE.txt %license COPYING # rugygem licenses @@ -568,6 +597,8 @@ remove_all_tests %{pcs_libdir}/pcs/bundled/packages/tornado* %{pcs_libdir}/pcs/bundled/packages/dacite* %{pcs_libdir}/pcs/bundled/packages/dataclasses* +%{pcs_libdir}/pcs/bundled/packages/dateutil* +%{pcs_libdir}/pcs/bundled/packages/python_dateutil* %{pcs_libdir}/pcs/bundled/packages/__pycache__/dataclasses.cpython-36.pyc %{_unitdir}/pcsd.service %{_unitdir}/pcsd-ruby.service @@ -613,6 +644,39 @@ remove_all_tests %license pyagentx_LICENSE.txt %changelog +* Mon Feb 01 2021 Miroslav Lisik <mlisik@redhat.com> - 0.10.8-1 +- Rebased to latest upstream sources (see CHANGELOG.md) +- Updated pcs-web-ui +- Updated python bundled dependencies: dacite, dataclasses +- Resolves: rhbz#1457314 rhbz#1619818 rhbz#1667066 rhbz#1762816 rhbz#1794062 rhbz#1845470 rhbz#1856397 rhbz#1877762 rhbz#1917286 + +* Thu Dec 17 2020 Miroslav Lisik <mlisik@redhat.com> - 0.10.7-3 +- Rebased to latest upstream sources (see CHANGELOG.md) +- Add BuildRequires: make +- Resolves: rhbz#1667061 rhbz#1667066 rhbz#1774143 rhbz#1885658 + +* Fri Nov 13 2020 Miroslav Lisik <mlisik@redhat.com> - 0.10.7-2 +- Rebased to latest upstream sources (see CHANGELOG.md) +- Changed BuildRequires from git to git-core +- Resolves: rhbz#1869399 rhbz#1885658 rhbz#1896379 + +* Wed Oct 14 2020 Miroslav Lisik <mlisik@redhat.com> - 0.10.7-1 +- Rebased to latest upstream sources (see CHANGELOG.md) +- Added python bundled dependency dateutil +- Fixed virtual bundle provides for ember, handelbars, jquery and jquery-ui +- Resolves: rhbz#1222691 rhbz#1741056 rhbz#1851335 rhbz#1862966 rhbz#1869399 rhbz#1873691 rhbz#1875301 rhbz#1883445 rhbz#1885658 rhbz#1885841 + +* Tue Aug 11 2020 Miroslav Lisik <mlisik@redhat.com> - 0.10.6-4 +- Fixed invalid CIB error caused by resource and operation defaults with mixed and-or rules +- Updated pcs-web-ui +- Resolves: rhbz#1867516 + +* Thu Jul 16 2020 Miroslav Lisik <mlisik@redhat.com> - 0.10.6-3 +- Added Upgrade CIB if user specifies on-fail=demote +- Fixed rpmdiff issue with binary stripping checker +- Fixed removing non-empty tag by removing tagged resource group or clone +- Resolves: rhbz#1843079 rhbz#1857295 + * Thu Jun 25 2020 Miroslav Lisik <mlisik@redhat.com> - 0.10.6-2 - Added resource and operation defaults that apply to specific resource/operation types - Added Requires/BuildRequires: python3-pyparsing