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&#x27;6&#x27;" value="value&quot;6&quot;"
-+                />
-+            </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