From ec4f8fc199891ad13235729272c0f115918cade9 Mon Sep 17 00:00:00 2001
From: Tomas Jelinek <tojeline@redhat.com>
Date: Thu, 21 May 2020 16:51:25 +0200
Subject: [PATCH 1/3] squash bz1817547 Resource and operation defaults that
apply to specific resource/operation types
add rule parser for rsc and op expressions
improvements to rule parser
make rule parts independent of the parser
export parsed rules into cib
add a command for adding new rsc and op defaults
display rsc and op defaults with multiple nvsets
fix parsing and processing of rsc_expression in rules
improve syntax for creating a new nvset
make the rule parser produce dataclasses
fix for pyparsing-2.4.0
add commands for removing rsc and op defaults sets
add commands for updating rsc and op defaults sets
update chagelog, capabilities
add tier1 tests for rules
various minor fixes
fix routing, create 'defaults update' command
better error messages for unallowed rule expressions
---
.gitlab-ci.yml | 3 +
README.md | 1 +
mypy.ini | 9 +
pcs.spec.in | 3 +
pcs/cli/common/lib_wrapper.py | 10 +-
pcs/cli/nvset.py | 53 ++
pcs/cli/reports/messages.py | 39 +
pcs/cli/routing/resource.py | 77 +-
pcs/cli/rule.py | 89 +++
pcs/common/interface/dto.py | 9 +-
pcs/common/pacemaker/nvset.py | 26 +
pcs/common/pacemaker/rule.py | 28 +
pcs/common/reports/codes.py | 3 +
pcs/common/reports/const.py | 6 +
pcs/common/reports/messages.py | 73 ++
pcs/common/reports/types.py | 1 +
pcs/common/str_tools.py | 32 +
pcs/common/types.py | 13 +
pcs/config.py | 20 +-
pcs/lib/cib/nvpair_multi.py | 323 +++++++++
pcs/lib/cib/rule/__init__.py | 8 +
pcs/lib/cib/rule/cib_to_dto.py | 185 +++++
pcs/lib/cib/rule/expression_part.py | 49 ++
pcs/lib/cib/rule/parsed_to_cib.py | 103 +++
pcs/lib/cib/rule/parser.py | 232 ++++++
pcs/lib/cib/rule/validator.py | 62 ++
pcs/lib/cib/tools.py | 8 +-
pcs/lib/commands/cib_options.py | 322 ++++++++-
pcs/lib/validate.py | 15 +
pcs/lib/xml_tools.py | 9 +-
pcs/pcs.8 | 86 ++-
pcs/resource.py | 258 ++++++-
pcs/usage.py | 94 ++-
pcs_test/resources/cib-empty-3.1.xml | 2 +-
pcs_test/resources/cib-empty-3.2.xml | 2 +-
pcs_test/resources/cib-empty-3.3.xml | 10 +
pcs_test/resources/cib-empty-3.4.xml | 10 +
pcs_test/resources/cib-empty.xml | 2 +-
pcs_test/tier0/cli/reports/test_messages.py | 29 +
pcs_test/tier0/cli/resource/test_defaults.py | 324 +++++++++
pcs_test/tier0/cli/test_nvset.py | 92 +++
pcs_test/tier0/cli/test_rule.py | 477 +++++++++++++
.../tier0/common/reports/test_messages.py | 55 +-
pcs_test/tier0/common/test_str_tools.py | 33 +
.../cib_options => cib/rule}/__init__.py | 0
.../tier0/lib/cib/rule/test_cib_to_dto.py | 593 ++++++++++++++++
.../tier0/lib/cib/rule/test_parsed_to_cib.py | 214 ++++++
pcs_test/tier0/lib/cib/rule/test_parser.py | 270 +++++++
pcs_test/tier0/lib/cib/rule/test_validator.py | 68 ++
pcs_test/tier0/lib/cib/test_nvpair_multi.py | 513 ++++++++++++++
pcs_test/tier0/lib/cib/test_tools.py | 13 +-
.../cib_options/test_operations_defaults.py | 120 ----
.../cib_options/test_resources_defaults.py | 120 ----
.../tier0/lib/commands/test_cib_options.py | 669 ++++++++++++++++++
pcs_test/tier0/lib/test_validate.py | 27 +
pcs_test/tier1/legacy/test_resource.py | 8 +-
pcs_test/tier1/legacy/test_stonith.py | 8 +-
pcs_test/tier1/test_cib_options.py | 571 +++++++++++++++
pcs_test/tier1/test_tag.py | 4 +-
pcs_test/tools/fixture.py | 4 +-
pcs_test/tools/misc.py | 61 +-
pcsd/capabilities.xml | 30 +
test/centos8/Dockerfile | 1 +
test/fedora30/Dockerfile | 1 +
test/fedora31/Dockerfile | 1 +
test/fedora32/Dockerfile | 1 +
66 files changed, 6216 insertions(+), 366 deletions(-)
create mode 100644 pcs/cli/nvset.py
create mode 100644 pcs/cli/rule.py
create mode 100644 pcs/common/pacemaker/nvset.py
create mode 100644 pcs/common/pacemaker/rule.py
create mode 100644 pcs/lib/cib/nvpair_multi.py
create mode 100644 pcs/lib/cib/rule/__init__.py
create mode 100644 pcs/lib/cib/rule/cib_to_dto.py
create mode 100644 pcs/lib/cib/rule/expression_part.py
create mode 100644 pcs/lib/cib/rule/parsed_to_cib.py
create mode 100644 pcs/lib/cib/rule/parser.py
create mode 100644 pcs/lib/cib/rule/validator.py
create mode 100644 pcs_test/resources/cib-empty-3.3.xml
create mode 100644 pcs_test/resources/cib-empty-3.4.xml
create mode 100644 pcs_test/tier0/cli/resource/test_defaults.py
create mode 100644 pcs_test/tier0/cli/test_nvset.py
create mode 100644 pcs_test/tier0/cli/test_rule.py
rename pcs_test/tier0/lib/{commands/cib_options => cib/rule}/__init__.py (100%)
create mode 100644 pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py
create mode 100644 pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py
create mode 100644 pcs_test/tier0/lib/cib/rule/test_parser.py
create mode 100644 pcs_test/tier0/lib/cib/rule/test_validator.py
create mode 100644 pcs_test/tier0/lib/cib/test_nvpair_multi.py
delete mode 100644 pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py
delete mode 100644 pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py
create mode 100644 pcs_test/tier0/lib/commands/test_cib_options.py
create mode 100644 pcs_test/tier1/test_cib_options.py
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 83eba12d..24444b72 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -51,6 +51,7 @@ pylint:
python3-pip
python3-pycurl
python3-pyOpenSSL
+ python3-pyparsing
findutils
make
time
@@ -69,6 +70,7 @@ mypy:
python3-pip
python3-pycurl
python3-pyOpenSSL
+ python3-pyparsing
git
make
tar
@@ -112,6 +114,7 @@ python_tier0_tests:
python3-pip
python3-pycurl
python3-pyOpenSSL
+ python3-pyparsing
which
"
- make install_pip
diff --git a/README.md b/README.md
index f888da68..efb4d0d5 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ These are the runtime dependencies of pcs and pcsd:
* python3-pycurl
* python3-setuptools
* python3-pyOpenSSL (python3-openssl)
+* python3-pyparsing
* python3-tornado 6.x
* python dataclasses (`pip install dataclasses`; required only for python 3.6,
already included in 3.7+)
diff --git a/mypy.ini b/mypy.ini
index ad3d1f18..ac6789a9 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -8,12 +8,18 @@ disallow_untyped_defs = True
[mypy-pcs.lib.cib.resource.relations]
disallow_untyped_defs = True
+[mypy-pcs.lib.cib.rule]
+disallow_untyped_defs = True
+
[mypy-pcs.lib.cib.tag]
disallow_untyped_defs = True
[mypy-pcs.lib.commands.tag]
disallow_untyped_defs = True
+[mypy-pcs.lib.commands.cib_options]
+disallow_untyped_defs = True
+
[mypy-pcs.lib.dr.*]
disallow_untyped_defs = True
disallow_untyped_calls = True
@@ -84,3 +90,6 @@ ignore_missing_imports = True
[mypy-distro]
ignore_missing_imports = True
+
+[mypy-pyparsing]
+ignore_missing_imports = True
diff --git a/pcs.spec.in b/pcs.spec.in
index c52c2fe4..e292a708 100644
--- a/pcs.spec.in
+++ b/pcs.spec.in
@@ -122,6 +122,8 @@ BuildRequires: platform-python-setuptools
%endif
BuildRequires: python3-devel
+# for tier0 tests
+BuildRequires: python3-pyparsing
# gcc for compiling custom rubygems
BuildRequires: gcc
@@ -155,6 +157,7 @@ Requires: platform-python-setuptools
Requires: python3-lxml
Requires: python3-pycurl
+Requires: python3-pyparsing
# clufter and its dependencies
Requires: python3-clufter => 0.70.0
%if "%{python3_version}" != "3.6" && "%{python3_version}" != "3.7"
diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py
index 9fd05ac0..192a3dac 100644
--- a/pcs/cli/common/lib_wrapper.py
+++ b/pcs/cli/common/lib_wrapper.py
@@ -388,8 +388,14 @@ def load_module(env, middleware_factory, name):
env,
middleware.build(middleware_factory.cib,),
{
- "set_operations_defaults": cib_options.set_operations_defaults,
- "set_resources_defaults": cib_options.set_resources_defaults,
+ "operation_defaults_config": cib_options.operation_defaults_config,
+ "operation_defaults_create": cib_options.operation_defaults_create,
+ "operation_defaults_remove": cib_options.operation_defaults_remove,
+ "operation_defaults_update": cib_options.operation_defaults_update,
+ "resource_defaults_config": cib_options.resource_defaults_config,
+ "resource_defaults_create": cib_options.resource_defaults_create,
+ "resource_defaults_remove": cib_options.resource_defaults_remove,
+ "resource_defaults_update": cib_options.resource_defaults_update,
},
)
diff --git a/pcs/cli/nvset.py b/pcs/cli/nvset.py
new file mode 100644
index 00000000..69442df3
--- /dev/null
+++ b/pcs/cli/nvset.py
@@ -0,0 +1,53 @@
+from typing import (
+ cast,
+ Iterable,
+ List,
+ Optional,
+)
+
+from pcs.cli.rule import rule_expression_dto_to_lines
+from pcs.common.pacemaker.nvset import CibNvsetDto
+from pcs.common.str_tools import (
+ format_name_value_list,
+ indent,
+)
+from pcs.common.types import CibNvsetType
+
+
+def nvset_dto_list_to_lines(
+ nvset_dto_list: Iterable[CibNvsetDto],
+ with_ids: bool = False,
+ text_if_empty: Optional[str] = None,
+) -> List[str]:
+ if not nvset_dto_list:
+ return [text_if_empty] if text_if_empty else []
+ return [
+ line
+ for nvset_dto in nvset_dto_list
+ for line in nvset_dto_to_lines(nvset_dto, with_ids=with_ids)
+ ]
+
+
+def nvset_dto_to_lines(nvset: CibNvsetDto, with_ids: bool = False) -> List[str]:
+ nvset_label = _nvset_type_to_label.get(nvset.type, "Options Set")
+ heading_parts = [f"{nvset_label}: {nvset.id}"]
+ if nvset.options:
+ heading_parts.append(
+ " ".join(format_name_value_list(sorted(nvset.options.items())))
+ )
+
+ lines = format_name_value_list(
+ sorted([(nvpair.name, nvpair.value) for nvpair in nvset.nvpairs])
+ )
+ if nvset.rule:
+ lines.extend(
+ rule_expression_dto_to_lines(nvset.rule, with_ids=with_ids)
+ )
+
+ return [" ".join(heading_parts)] + indent(lines)
+
+
+_nvset_type_to_label = {
+ cast(str, CibNvsetType.INSTANCE): "Attributes",
+ cast(str, CibNvsetType.META): "Meta Attrs",
+}
diff --git a/pcs/cli/reports/messages.py b/pcs/cli/reports/messages.py
index 36f00a9e..7ccc8ab0 100644
--- a/pcs/cli/reports/messages.py
+++ b/pcs/cli/reports/messages.py
@@ -402,6 +402,45 @@ class TagCannotRemoveReferencesWithoutRemovingTag(CliReportMessageCustom):
)
+class RuleExpressionParseError(CliReportMessageCustom):
+ _obj: messages.RuleExpressionParseError
+
+ @property
+ def message(self) -> str:
+ # Messages coming from the parser are not very useful and readable,
+ # they mostly contain one line grammar expression covering the whole
+ # rule. No user would be able to parse that. Therefore we omit the
+ # messages.
+ marker = "-" * (self._obj.column_number - 1) + "^"
+ return (
+ f"'{self._obj.rule_string}' is not a valid rule expression, parse "
+ f"error near or after line {self._obj.line_number} column "
+ f"{self._obj.column_number}\n"
+ f" {self._obj.rule_line}\n"
+ f" {marker}"
+ )
+
+
+class CibNvsetAmbiguousProvideNvsetId(CliReportMessageCustom):
+ _obj: messages.CibNvsetAmbiguousProvideNvsetId
+
+ @property
+ def message(self) -> str:
+ command_map = {
+ const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE: (
+ "pcs resource defaults set update"
+ ),
+ const.PCS_COMMAND_OPERATION_DEFAULTS_UPDATE: (
+ "pcs resource op defaults set update"
+ ),
+ }
+ command = command_map.get(self._obj.pcs_command, "")
+ return (
+ f"Several options sets exist, please use the '{command}' command "
+ "and specify an option set ID"
+ )
+
+
def _create_report_msg_map() -> Dict[str, type]:
result: Dict[str, type] = {}
for report_msg_cls in get_all_subclasses(CliReportMessageCustom):
diff --git a/pcs/cli/routing/resource.py b/pcs/cli/routing/resource.py
index 28bb3d5e..0706f43b 100644
--- a/pcs/cli/routing/resource.py
+++ b/pcs/cli/routing/resource.py
@@ -1,15 +1,88 @@
from functools import partial
+from typing import (
+ Any,
+ List,
+)
from pcs import (
resource,
usage,
)
from pcs.cli.common.errors import raise_command_replaced
+from pcs.cli.common.parse_args import InputModifiers
from pcs.cli.common.routing import create_router
from pcs.cli.resource.relations import show_resource_relations_cmd
+def resource_defaults_cmd(
+ lib: Any, argv: List[str], modifiers: InputModifiers
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --force - allow unknown options
+ """
+ if argv and "=" in argv[0]:
+ # DEPRECATED legacy command
+ return resource.resource_defaults_legacy_cmd(
+ lib, argv, modifiers, deprecated_syntax_used=True
+ )
+
+ router = create_router(
+ {
+ "config": resource.resource_defaults_config_cmd,
+ "set": create_router(
+ {
+ "create": resource.resource_defaults_set_create_cmd,
+ "delete": resource.resource_defaults_set_remove_cmd,
+ "remove": resource.resource_defaults_set_remove_cmd,
+ "update": resource.resource_defaults_set_update_cmd,
+ },
+ ["resource", "defaults", "set"],
+ ),
+ "update": resource.resource_defaults_legacy_cmd,
+ },
+ ["resource", "defaults"],
+ default_cmd="config",
+ )
+ return router(lib, argv, modifiers)
+
+
+def resource_op_defaults_cmd(
+ lib: Any, argv: List[str], modifiers: InputModifiers
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --force - allow unknown options
+ """
+ if argv and "=" in argv[0]:
+ # DEPRECATED legacy command
+ return resource.resource_op_defaults_legacy_cmd(
+ lib, argv, modifiers, deprecated_syntax_used=True
+ )
+
+ router = create_router(
+ {
+ "config": resource.resource_op_defaults_config_cmd,
+ "set": create_router(
+ {
+ "create": resource.resource_op_defaults_set_create_cmd,
+ "delete": resource.resource_op_defaults_set_remove_cmd,
+ "remove": resource.resource_op_defaults_set_remove_cmd,
+ "update": resource.resource_op_defaults_set_update_cmd,
+ },
+ ["resource", "op", "defaults", "set"],
+ ),
+ "update": resource.resource_op_defaults_legacy_cmd,
+ },
+ ["resource", "op", "defaults"],
+ default_cmd="config",
+ )
+ return router(lib, argv, modifiers)
+
+
resource_cmd = create_router(
{
"help": lambda lib, argv, modifiers: usage.resource(argv),
@@ -68,14 +141,14 @@ resource_cmd = create_router(
"failcount": resource.resource_failcount,
"op": create_router(
{
- "defaults": resource.resource_op_defaults_cmd,
+ "defaults": resource_op_defaults_cmd,
"add": resource.resource_op_add_cmd,
"remove": resource.resource_op_delete_cmd,
"delete": resource.resource_op_delete_cmd,
},
["resource", "op"],
),
- "defaults": resource.resource_defaults_cmd,
+ "defaults": resource_defaults_cmd,
"cleanup": resource.resource_cleanup,
"refresh": resource.resource_refresh,
"relocate": create_router(
diff --git a/pcs/cli/rule.py b/pcs/cli/rule.py
new file mode 100644
index 00000000..c1149fff
--- /dev/null
+++ b/pcs/cli/rule.py
@@ -0,0 +1,89 @@
+from typing import List
+
+from pcs.common.pacemaker.rule import CibRuleExpressionDto
+from pcs.common.str_tools import (
+ format_name_value_list,
+ indent,
+)
+from pcs.common.types import CibRuleExpressionType
+
+
+def rule_expression_dto_to_lines(
+ rule_expr: CibRuleExpressionDto, with_ids: bool = False
+) -> List[str]:
+ if rule_expr.type == CibRuleExpressionType.RULE:
+ return _rule_dto_to_lines(rule_expr, with_ids)
+ if rule_expr.type == CibRuleExpressionType.DATE_EXPRESSION:
+ return _date_dto_to_lines(rule_expr, with_ids)
+ return _simple_expr_to_lines(rule_expr, with_ids)
+
+
+def _rule_dto_to_lines(
+ rule_expr: CibRuleExpressionDto, with_ids: bool = False
+) -> List[str]:
+ heading_parts = [
+ "Rule{0}:".format(" (expired)" if rule_expr.is_expired else "")
+ ]
+ heading_parts.extend(
+ format_name_value_list(sorted(rule_expr.options.items()))
+ )
+ if with_ids:
+ heading_parts.append(f"(id:{rule_expr.id})")
+
+ lines = []
+ for child in rule_expr.expressions:
+ lines.extend(rule_expression_dto_to_lines(child, with_ids))
+
+ return [" ".join(heading_parts)] + indent(lines)
+
+
+def _date_dto_to_lines(
+ rule_expr: CibRuleExpressionDto, with_ids: bool = False
+) -> List[str]:
+ # pylint: disable=too-many-branches
+ operation = rule_expr.options.get("operation", None)
+
+ if operation == "date_spec":
+ heading_parts = ["Expression:"]
+ if with_ids:
+ heading_parts.append(f"(id:{rule_expr.id})")
+ line_parts = ["Date Spec:"]
+ if rule_expr.date_spec:
+ line_parts.extend(
+ format_name_value_list(
+ sorted(rule_expr.date_spec.options.items())
+ )
+ )
+ if with_ids:
+ line_parts.append(f"(id:{rule_expr.date_spec.id})")
+ return [" ".join(heading_parts)] + indent([" ".join(line_parts)])
+
+ if operation == "in_range" and rule_expr.duration:
+ heading_parts = ["Expression:", "date", "in_range"]
+ if "start" in rule_expr.options:
+ heading_parts.append(rule_expr.options["start"])
+ heading_parts.extend(["to", "duration"])
+ if with_ids:
+ heading_parts.append(f"(id:{rule_expr.id})")
+ lines = [" ".join(heading_parts)]
+
+ line_parts = ["Duration:"]
+ line_parts.extend(
+ format_name_value_list(sorted(rule_expr.duration.options.items()))
+ )
+ if with_ids:
+ line_parts.append(f"(id:{rule_expr.duration.id})")
+ lines.extend(indent([" ".join(line_parts)]))
+
+ return lines
+
+ return _simple_expr_to_lines(rule_expr, with_ids=with_ids)
+
+
+def _simple_expr_to_lines(
+ rule_expr: CibRuleExpressionDto, with_ids: bool = False
+) -> List[str]:
+ parts = ["Expression:", rule_expr.as_string]
+ if with_ids:
+ parts.append(f"(id:{rule_expr.id})")
+ return [" ".join(parts)]
diff --git a/pcs/common/interface/dto.py b/pcs/common/interface/dto.py
index fb40fc5e..768156d6 100644
--- a/pcs/common/interface/dto.py
+++ b/pcs/common/interface/dto.py
@@ -42,7 +42,14 @@ def from_dict(cls: Type[DtoType], data: DtoPayload) -> DtoType:
data=data,
# NOTE: all enum types has to be listed here in key cast
# see: https://github.com/konradhalas/dacite#casting
- config=dacite.Config(cast=[types.DrRole, types.ResourceRelationType,],),
+ config=dacite.Config(
+ cast=[
+ types.CibNvsetType,
+ types.CibRuleExpressionType,
+ types.DrRole,
+ types.ResourceRelationType,
+ ]
+ ),
)
diff --git a/pcs/common/pacemaker/nvset.py b/pcs/common/pacemaker/nvset.py
new file mode 100644
index 00000000..6d72c787
--- /dev/null
+++ b/pcs/common/pacemaker/nvset.py
@@ -0,0 +1,26 @@
+from dataclasses import dataclass
+from typing import (
+ Mapping,
+ Optional,
+ Sequence,
+)
+
+from pcs.common.interface.dto import DataTransferObject
+from pcs.common.pacemaker.rule import CibRuleExpressionDto
+from pcs.common.types import CibNvsetType
+
+
+@dataclass(frozen=True)
+class CibNvpairDto(DataTransferObject):
+ id: str # pylint: disable=invalid-name
+ name: str
+ value: str
+
+
+@dataclass(frozen=True)
+class CibNvsetDto(DataTransferObject):
+ id: str # pylint: disable=invalid-name
+ type: CibNvsetType
+ options: Mapping[str, str]
+ rule: Optional[CibRuleExpressionDto]
+ nvpairs: Sequence[CibNvpairDto]
diff --git a/pcs/common/pacemaker/rule.py b/pcs/common/pacemaker/rule.py
new file mode 100644
index 00000000..306e65e6
--- /dev/null
+++ b/pcs/common/pacemaker/rule.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass
+from typing import (
+ Mapping,
+ Optional,
+ Sequence,
+)
+
+from pcs.common.interface.dto import DataTransferObject
+from pcs.common.types import CibRuleExpressionType
+
+
+@dataclass(frozen=True)
+class CibRuleDateCommonDto(DataTransferObject):
+ id: str # pylint: disable=invalid-name
+ options: Mapping[str, str]
+
+
+@dataclass(frozen=True)
+class CibRuleExpressionDto(DataTransferObject):
+ # pylint: disable=too-many-instance-attributes
+ id: str # pylint: disable=invalid-name
+ type: CibRuleExpressionType
+ is_expired: bool # only valid for type==rule
+ options: Mapping[str, str]
+ date_spec: Optional[CibRuleDateCommonDto]
+ duration: Optional[CibRuleDateCommonDto]
+ expressions: Sequence["CibRuleExpressionDto"]
+ as_string: str
diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py
index 26eb8b51..8bcabfab 100644
--- a/pcs/common/reports/codes.py
+++ b/pcs/common/reports/codes.py
@@ -123,6 +123,7 @@ CIB_LOAD_ERROR = M("CIB_LOAD_ERROR")
CIB_LOAD_ERROR_GET_NODES_FOR_VALIDATION = M(
"CIB_LOAD_ERROR_GET_NODES_FOR_VALIDATION"
)
+CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID = M("CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID")
CIB_LOAD_ERROR_SCOPE_MISSING = M("CIB_LOAD_ERROR_SCOPE_MISSING")
CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET = M(
"CIB_PUSH_FORCED_FULL_DUE_TO_CRM_FEATURE_SET"
@@ -405,6 +406,8 @@ RESOURCE_UNMOVE_UNBAN_PCMK_SUCCESS = M("RESOURCE_UNMOVE_UNBAN_PCMK_SUCCESS")
RESOURCE_UNMOVE_UNBAN_PCMK_EXPIRED_NOT_SUPPORTED = M(
"RESOURCE_UNMOVE_UNBAN_PCMK_EXPIRED_NOT_SUPPORTED"
)
+RULE_EXPRESSION_PARSE_ERROR = M("RULE_EXPRESSION_PARSE_ERROR")
+RULE_EXPRESSION_NOT_ALLOWED = M("RULE_EXPRESSION_NOT_ALLOWED")
RUN_EXTERNAL_PROCESS_ERROR = M("RUN_EXTERNAL_PROCESS_ERROR")
RUN_EXTERNAL_PROCESS_FINISHED = M("RUN_EXTERNAL_PROCESS_FINISHED")
RUN_EXTERNAL_PROCESS_STARTED = M("RUN_EXTERNAL_PROCESS_STARTED")
diff --git a/pcs/common/reports/const.py b/pcs/common/reports/const.py
index aeb593ee..fa2122d0 100644
--- a/pcs/common/reports/const.py
+++ b/pcs/common/reports/const.py
@@ -1,9 +1,15 @@
from .types import (
DefaultAddressSource,
+ PcsCommand,
ReasonType,
ServiceAction,
)
+PCS_COMMAND_OPERATION_DEFAULTS_UPDATE = PcsCommand(
+ "resource op defaults update"
+)
+PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE = PcsCommand("resource defaults update")
+
SERVICE_ACTION_START = ServiceAction("START")
SERVICE_ACTION_STOP = ServiceAction("STOP")
SERVICE_ACTION_ENABLE = ServiceAction("ENABLE")
diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py
index 540e8c69..f04d8632 100644
--- a/pcs/common/reports/messages.py
+++ b/pcs/common/reports/messages.py
@@ -27,6 +27,7 @@ from pcs.common.str_tools import (
indent,
is_iterable_not_str,
)
+from pcs.common.types import CibRuleExpressionType
from . import (
codes,
@@ -120,6 +121,7 @@ _type_articles = {
"ACL user": "an",
"ACL role": "an",
"ACL permission": "an",
+ "options set": "an",
}
@@ -6399,3 +6401,74 @@ class TagIdsNotInTheTag(ReportItemMessage):
ids=format_plural(self.id_list, "id"),
id_list=format_list(self.id_list),
)
+
+
+@dataclass(frozen=True)
+class RuleExpressionParseError(ReportItemMessage):
+ """
+ Unable to parse pacemaker cib rule expression string
+
+ rule_string -- the whole rule expression string
+ reason -- error message from rule parser
+ rule_line -- part of rule_string - the line where the error occurred
+ line_number -- the line where parsing failed
+ column_number -- the column where parsing failed
+ position -- the start index where parsing failed
+ """
+
+ rule_string: str
+ reason: str
+ rule_line: str
+ line_number: int
+ column_number: int
+ position: int
+ _code = codes.RULE_EXPRESSION_PARSE_ERROR
+
+ @property
+ def message(self) -> str:
+ # Messages coming from the parser are not very useful and readable,
+ # they mostly contain one line grammar expression covering the whole
+ # rule. No user would be able to parse that. Therefore we omit the
+ # messages.
+ return (
+ f"'{self.rule_string}' is not a valid rule expression, parse error "
+ f"near or after line {self.line_number} column {self.column_number}"
+ )
+
+
+@dataclass(frozen=True)
+class RuleExpressionNotAllowed(ReportItemMessage):
+ """
+ Used rule expression is not allowed in current context
+
+ expression_type -- disallowed expression type
+ """
+
+ expression_type: CibRuleExpressionType
+ _code = codes.RULE_EXPRESSION_NOT_ALLOWED
+
+ @property
+ def message(self) -> str:
+ type_map = {
+ CibRuleExpressionType.OP_EXPRESSION: "op",
+ CibRuleExpressionType.RSC_EXPRESSION: "resource",
+ }
+ return (
+ f"Keyword '{type_map[self.expression_type]}' cannot be used "
+ "in a rule in this command"
+ )
+
+
+@dataclass(frozen=True)
+class CibNvsetAmbiguousProvideNvsetId(ReportItemMessage):
+ """
+ An old command supporting only one nvset have been used when several nvsets
+ exist. We require an nvset ID the command should work with to be specified.
+ """
+
+ pcs_command: types.PcsCommand
+ _code = codes.CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID
+
+ @property
+ def message(self) -> str:
+ return "Several options sets exist, please specify an option set ID"
diff --git a/pcs/common/reports/types.py b/pcs/common/reports/types.py
index 5973279e..541046ea 100644
--- a/pcs/common/reports/types.py
+++ b/pcs/common/reports/types.py
@@ -3,6 +3,7 @@ from typing import NewType
DefaultAddressSource = NewType("DefaultAddressSource", str)
ForceCode = NewType("ForceCode", str)
MessageCode = NewType("MessageCode", str)
+PcsCommand = NewType("PcsCommand", str)
ReasonType = NewType("ReasonType", str)
ServiceAction = NewType("ServiceAction", str)
SeverityLevel = NewType("SeverityLevel", str)
diff --git a/pcs/common/str_tools.py b/pcs/common/str_tools.py
index deb38799..80864b50 100644
--- a/pcs/common/str_tools.py
+++ b/pcs/common/str_tools.py
@@ -3,6 +3,8 @@ from typing import (
Any,
List,
Mapping,
+ Sequence,
+ Tuple,
TypeVar,
)
@@ -49,6 +51,36 @@ def format_list_custom_last_separator(
)
+# For now, Tuple[str, str] is sufficient. Feel free to change it if needed,
+# e.g. when values can be integers.
+def format_name_value_list(item_list: Sequence[Tuple[str, str]]) -> List[str]:
+ """
+ Turn 2-tuples to 'name=value' strings with standard quoting
+ """
+ output = []
+ for name, value in item_list:
+ name = quote(name, "= ")
+ value = quote(value, "= ")
+ output.append(f"{name}={value}")
+ return output
+
+
+def quote(string: str, chars_to_quote: str) -> str:
+ """
+ Quote a string if it contains specified characters
+
+ string -- the string to be processed
+ chars_to_quote -- the characters causing quoting
+ """
+ if not frozenset(chars_to_quote) & frozenset(string):
+ return string
+ if '"' not in string:
+ return f'"{string}"'
+ if "'" not in string:
+ return f"'{string}'"
+ return '"{string}"'.format(string=string.replace('"', '\\"'))
+
+
def join_multilines(strings):
return "\n".join([a.strip() for a in strings if a.strip()])
diff --git a/pcs/common/types.py b/pcs/common/types.py
index dace6f6d..0b656cc0 100644
--- a/pcs/common/types.py
+++ b/pcs/common/types.py
@@ -3,6 +3,19 @@ from enum import auto
from pcs.common.tools import AutoNameEnum
+class CibNvsetType(AutoNameEnum):
+ INSTANCE = auto()
+ META = auto()
+
+
+class CibRuleExpressionType(AutoNameEnum):
+ RULE = auto()
+ EXPRESSION = auto()
+ DATE_EXPRESSION = auto()
+ OP_EXPRESSION = auto()
+ RSC_EXPRESSION = auto()
+
+
class ResourceRelationType(AutoNameEnum):
ORDER = auto()
ORDER_SET = auto()
diff --git a/pcs/config.py b/pcs/config.py
index 058ec55a..67aa6e0e 100644
--- a/pcs/config.py
+++ b/pcs/config.py
@@ -48,6 +48,7 @@ from pcs import (
from pcs.cli.common import middleware
from pcs.cli.common.errors import CmdLineInputError
from pcs.cli.constraint import command as constraint_command
+from pcs.cli.nvset import nvset_dto_list_to_lines
from pcs.cli.reports import process_library_reports
from pcs.common.reports import constraints as constraints_reports
from pcs.common.str_tools import indent
@@ -96,7 +97,8 @@ def _config_show_cib_lines(lib):
Commandline options:
* -f - CIB file
"""
- # update of pcs_options will change output of constraint show
+ # update of pcs_options will change output of constraint show and
+ # displaying resources and operations defaults
utils.pcs_options["--full"] = 1
# get latest modifiers object after updating pcs_options
modifiers = utils.get_input_modifiers()
@@ -172,11 +174,23 @@ def _config_show_cib_lines(lib):
all_lines.append("")
all_lines.append("Resources Defaults:")
all_lines.extend(
- indent(resource.show_defaults(cib_dom, "rsc_defaults"), indent_step=1)
+ indent(
+ nvset_dto_list_to_lines(
+ lib.cib_options.resource_defaults_config(),
+ with_ids=modifiers.get("--full"),
+ text_if_empty="No defaults set",
+ )
+ )
)
all_lines.append("Operations Defaults:")
all_lines.extend(
- indent(resource.show_defaults(cib_dom, "op_defaults"), indent_step=1)
+ indent(
+ nvset_dto_list_to_lines(
+ lib.cib_options.operation_defaults_config(),
+ with_ids=modifiers.get("--full"),
+ text_if_empty="No defaults set",
+ )
+ )
)
all_lines.append("")
diff --git a/pcs/lib/cib/nvpair_multi.py b/pcs/lib/cib/nvpair_multi.py
new file mode 100644
index 00000000..7bdc2f55
--- /dev/null
+++ b/pcs/lib/cib/nvpair_multi.py
@@ -0,0 +1,323 @@
+from typing import (
+ cast,
+ Iterable,
+ List,
+ Mapping,
+ NewType,
+ Optional,
+ Tuple,
+)
+from xml.etree.ElementTree import Element
+
+from lxml import etree
+from lxml.etree import _Element
+
+from pcs.common import reports
+from pcs.common.pacemaker.nvset import (
+ CibNvpairDto,
+ CibNvsetDto,
+)
+from pcs.common.reports import ReportItemList
+from pcs.common.types import CibNvsetType
+from pcs.lib import validate
+from pcs.lib.cib.rule import (
+ RuleParseError,
+ RuleRoot,
+ RuleValidator,
+ parse_rule,
+ rule_element_to_dto,
+ rule_to_cib,
+)
+from pcs.lib.cib.tools import (
+ ElementSearcher,
+ IdProvider,
+ create_subelement_id,
+)
+from pcs.lib.xml_tools import (
+ export_attributes,
+ remove_one_element,
+)
+
+
+NvsetTag = NewType("NvsetTag", str)
+NVSET_INSTANCE = NvsetTag("instance_attributes")
+NVSET_META = NvsetTag("meta_attributes")
+
+_tag_to_type = {
+ str(NVSET_META): CibNvsetType.META,
+ str(NVSET_INSTANCE): CibNvsetType.INSTANCE,
+}
+
+
+def nvpair_element_to_dto(nvpair_el: Element) -> CibNvpairDto:
+ """
+ Export an nvpair xml element to its DTO
+ """
+ return CibNvpairDto(
+ nvpair_el.get("id", ""),
+ nvpair_el.get("name", ""),
+ nvpair_el.get("value", ""),
+ )
+
+
+def nvset_element_to_dto(nvset_el: Element) -> CibNvsetDto:
+ """
+ Export an nvset xml element to its DTO
+ """
+ rule_el = nvset_el.find("./rule")
+ return CibNvsetDto(
+ nvset_el.get("id", ""),
+ _tag_to_type[nvset_el.tag],
+ export_attributes(nvset_el, with_id=False),
+ None if rule_el is None else rule_element_to_dto(rule_el),
+ [
+ nvpair_element_to_dto(nvpair_el)
+ for nvpair_el in nvset_el.iterfind("./nvpair")
+ ],
+ )
+
+
+def find_nvsets(parent_element: Element) -> List[Element]:
+ """
+ Get all nvset xml elements in the given parent element
+
+ parent_element -- an element to look for nvsets in
+ """
+ return cast(
+ # The xpath method has a complicated return value, but we know our xpath
+ # expression returns only elements.
+ List[Element],
+ cast(_Element, parent_element).xpath(
+ "./*[{nvset_tags}]".format(
+ nvset_tags=" or ".join(f"self::{tag}" for tag in _tag_to_type)
+ )
+ ),
+ )
+
+
+def find_nvsets_by_ids(
+ parent_element: Element, id_list: Iterable[str]
+) -> Tuple[List[Element], ReportItemList]:
+ """
+ Find nvset elements by their IDs and return them with non-empty report
+ list in case of errors.
+
+ parent_element -- an element to look for nvsets in
+ id_list -- nvset IDs to be looked for
+ """
+ element_list = []
+ report_list: ReportItemList = []
+ for nvset_id in id_list:
+ searcher = ElementSearcher(
+ _tag_to_type.keys(),
+ nvset_id,
+ parent_element,
+ element_type_desc="options set",
+ )
+ if searcher.element_found():
+ element_list.append(searcher.get_element())
+ else:
+ report_list.extend(searcher.get_errors())
+ return element_list, report_list
+
+
+class ValidateNvsetAppendNew:
+ """
+ Validator for creating new nvset and appending it to CIB
+ """
+
+ def __init__(
+ self,
+ id_provider: IdProvider,
+ nvpair_dict: Mapping[str, str],
+ nvset_options: Mapping[str, str],
+ nvset_rule: Optional[str] = None,
+ rule_allows_rsc_expr: bool = False,
+ rule_allows_op_expr: bool = False,
+ ):
+ """
+ id_provider -- elements' ids generator
+ nvpair_dict -- nvpairs to be put into the new nvset
+ nvset_options -- additional attributes of the created nvset
+ nvset_rule -- optional rule describing when the created nvset applies
+ rule_allows_rsc_expr -- is rsc_expression element allowed in nvset_rule?
+ rule_allows_op_expr -- is op_expression element allowed in nvset_rule?
+ """
+ self._id_provider = id_provider
+ self._nvpair_dict = nvpair_dict
+ self._nvset_options = nvset_options
+ self._nvset_rule = nvset_rule
+ self._allow_rsc_expr = rule_allows_rsc_expr
+ self._allow_op_expr = rule_allows_op_expr
+ self._nvset_rule_parsed: Optional[RuleRoot] = None
+
+ def validate(self, force_options: bool = False) -> reports.ReportItemList:
+ report_list: reports.ReportItemList = []
+
+ # Nvpair dict is intentionally not validated: it may contain any keys
+ # and values. This can change in the future and then we add a
+ # validation. Until then there is really nothing to validate there.
+
+ # validate nvset options
+ validators = [
+ validate.NamesIn(
+ ("id", "score"),
+ **validate.set_warning(
+ reports.codes.FORCE_OPTIONS, force_options
+ ),
+ ),
+ # with id_provider it validates that the id is available as well
+ validate.ValueId(
+ "id", option_name_for_report="id", id_provider=self._id_provider
+ ),
+ validate.ValueScore("score"),
+ ]
+ report_list.extend(
+ validate.ValidatorAll(validators).validate(self._nvset_options)
+ )
+
+ # parse and validate rule
+ # TODO write and call parsed rule validation and cleanup and tests
+ if self._nvset_rule:
+ try:
+ # Allow flags are set to True always, the parsed rule tree is
+ # checked in the validator instead. That gives us better error
+ # messages, such as "op expression cannot be used in this
+ # context" instead of a universal "parse error".
+ self._nvset_rule_parsed = parse_rule(
+ self._nvset_rule, allow_rsc_expr=True, allow_op_expr=True
+ )
+ report_list.extend(
+ RuleValidator(
+ self._nvset_rule_parsed,
+ allow_rsc_expr=self._allow_rsc_expr,
+ allow_op_expr=self._allow_op_expr,
+ ).get_reports()
+ )
+ except RuleParseError as e:
+ report_list.append(
+ reports.ReportItem.error(
+ reports.messages.RuleExpressionParseError(
+ e.rule_string,
+ e.msg,
+ e.rule_line,
+ e.lineno,
+ e.colno,
+ e.pos,
+ )
+ )
+ )
+
+ return report_list
+
+ def get_parsed_rule(self) -> Optional[RuleRoot]:
+ return self._nvset_rule_parsed
+
+
+def nvset_append_new(
+ parent_element: Element,
+ id_provider: IdProvider,
+ nvset_tag: NvsetTag,
+ nvpair_dict: Mapping[str, str],
+ nvset_options: Mapping[str, str],
+ nvset_rule: Optional[RuleRoot] = None,
+) -> Element:
+ """
+ Create new nvset and append it to CIB
+
+ parent_element -- the created nvset will be appended into this element
+ id_provider -- elements' ids generator
+ nvset_tag -- type and actual tag of the nvset
+ nvpair_dict -- nvpairs to be put into the new nvset
+ nvset_options -- additional attributes of the created nvset
+ nvset_rule -- optional rule describing when the created nvset applies
+ """
+ nvset_options = dict(nvset_options) # make a copy which we can modify
+ if "id" not in nvset_options or not nvset_options["id"]:
+ nvset_options["id"] = create_subelement_id(
+ parent_element, nvset_tag, id_provider
+ )
+
+ nvset_el = etree.SubElement(cast(_Element, parent_element), nvset_tag)
+ for name, value in nvset_options.items():
+ if value != "":
+ nvset_el.attrib[name] = value
+ if nvset_rule:
+ rule_to_cib(cast(Element, nvset_el), id_provider, nvset_rule)
+ for name, value in nvpair_dict.items():
+ _set_nvpair(cast(Element, nvset_el), id_provider, name, value)
+ return cast(Element, nvset_el)
+
+
+def nvset_remove(nvset_el_list: Iterable[Element]) -> None:
+ """
+ Remove given nvset elements from CIB
+
+ nvset_el_list -- nvset elements to be removed
+ """
+ for nvset_el in nvset_el_list:
+ remove_one_element(nvset_el)
+
+
+def nvset_update(
+ nvset_el: Element, id_provider: IdProvider, nvpair_dict: Mapping[str, str],
+) -> None:
+ """
+ Update an existing nvset
+
+ nvset_el -- nvset to be updated
+ id_provider -- elements' ids generator
+ nvpair_dict -- nvpairs to be put into the nvset
+ """
+ # Do not ever remove the nvset element, even if it is empty. There may be
+ # ACLs set in pacemaker which allow "write" for nvpairs (adding, changing
+ # and removing) but not nvsets. In such a case, removing the nvset would
+ # cause the whole change to be rejected by pacemaker with a "permission
+ # denied" message.
+ # https://bugzilla.redhat.com/show_bug.cgi?id=1642514
+ for name, value in nvpair_dict.items():
+ _set_nvpair(nvset_el, id_provider, name, value)
+
+
+def _set_nvpair(
+ nvset_element: Element, id_provider: IdProvider, name: str, value: str
+):
+ """
+ Ensure name-value pair is set / removed in specified nvset
+
+ nvset_element -- container for nvpair elements to update
+ id_provider -- elements' ids generator
+ name -- name of the nvpair to be set
+ value -- value of the nvpair to be set, if "" the nvpair will be removed
+ """
+ nvpair_el_list = cast(
+ # The xpath method has a complicated return value, but we know our xpath
+ # expression returns only elements.
+ List[Element],
+ cast(_Element, nvset_element).xpath("./nvpair[@name=$name]", name=name),
+ )
+
+ if not nvpair_el_list:
+ if value != "":
+ etree.SubElement(
+ cast(_Element, nvset_element),
+ "nvpair",
+ {
+ "id": create_subelement_id(
+ nvset_element,
+ # limit id length to prevent excessively long ids
+ name[:20],
+ id_provider,
+ ),
+ "name": name,
+ "value": value,
+ },
+ )
+ return
+
+ if value != "":
+ nvpair_el_list[0].set("value", value)
+ else:
+ nvset_element.remove(nvpair_el_list[0])
+ for nvpair_el in nvpair_el_list[1:]:
+ nvset_element.remove(nvpair_el)
diff --git a/pcs/lib/cib/rule/__init__.py b/pcs/lib/cib/rule/__init__.py
new file mode 100644
index 00000000..94228572
--- /dev/null
+++ b/pcs/lib/cib/rule/__init__.py
@@ -0,0 +1,8 @@
+from .cib_to_dto import rule_element_to_dto
+from .expression_part import BoolExpr as RuleRoot
+from .parser import (
+ parse_rule,
+ RuleParseError,
+)
+from .parsed_to_cib import export as rule_to_cib
+from .validator import Validator as RuleValidator
diff --git a/pcs/lib/cib/rule/cib_to_dto.py b/pcs/lib/cib/rule/cib_to_dto.py
new file mode 100644
index 00000000..d8198e0c
--- /dev/null
+++ b/pcs/lib/cib/rule/cib_to_dto.py
@@ -0,0 +1,185 @@
+from typing import cast
+from xml.etree.ElementTree import Element
+
+from lxml.etree import _Element
+
+from pcs.common.pacemaker.rule import (
+ CibRuleDateCommonDto,
+ CibRuleExpressionDto,
+)
+from pcs.common.str_tools import (
+ format_name_value_list,
+ quote,
+)
+from pcs.common.types import CibRuleExpressionType
+from pcs.lib.xml_tools import export_attributes
+
+
+def rule_element_to_dto(rule_el: Element) -> CibRuleExpressionDto:
+ """
+ Export a rule xml element including its children to their DTOs
+ """
+ return _tag_to_export[rule_el.tag](rule_el)
+
+
+def _attrs_to_str(el: Element) -> str:
+ return " ".join(
+ format_name_value_list(
+ sorted(export_attributes(el, with_id=False).items())
+ )
+ )
+
+
+def _rule_to_dto(rule_el: Element) -> CibRuleExpressionDto:
+ children_dto_list = [
+ _tag_to_export[child.tag](child)
+ # The xpath method has a complicated return value, but we know our xpath
+ # expression only returns elements.
+ for child in cast(
+ Element, cast(_Element, rule_el).xpath(_xpath_for_export)
+ )
+ ]
+ # "and" is a documented pacemaker default
+ # https://clusterlabs.org/pacemaker/doc/en-US/Pacemaker/2.0/html-single/Pacemaker_Explained/index.html#_rule_properties
+ boolean_op = rule_el.get("boolean-op", "and")
+ string_parts = []
+ for child_dto in children_dto_list:
+ if child_dto.type == CibRuleExpressionType.RULE:
+ string_parts.append(f"({child_dto.as_string})")
+ else:
+ string_parts.append(child_dto.as_string)
+ return CibRuleExpressionDto(
+ rule_el.get("id", ""),
+ _tag_to_type[rule_el.tag],
+ False, # TODO implement is_expired
+ export_attributes(rule_el, with_id=False),
+ None,
+ None,
+ children_dto_list,
+ f" {boolean_op} ".join(string_parts),
+ )
+
+
+def _common_expr_to_dto(
+ expr_el: Element, as_string: str
+) -> CibRuleExpressionDto:
+ return CibRuleExpressionDto(
+ expr_el.get("id", ""),
+ _tag_to_type[expr_el.tag],
+ False,
+ export_attributes(expr_el, with_id=False),
+ None,
+ None,
+ [],
+ as_string,
+ )
+
+
+def _simple_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto:
+ string_parts = []
+ if "value" in expr_el.attrib:
+ # "attribute" and "operation" are defined as mandatory in CIB schema
+ string_parts.extend(
+ [expr_el.get("attribute", ""), expr_el.get("operation", "")]
+ )
+ if "type" in expr_el.attrib:
+ string_parts.append(expr_el.get("type", ""))
+ string_parts.append(quote(expr_el.get("value", ""), " "))
+ else:
+ # "attribute" and "operation" are defined as mandatory in CIB schema
+ string_parts.extend(
+ [expr_el.get("operation", ""), expr_el.get("attribute", "")]
+ )
+ return _common_expr_to_dto(expr_el, " ".join(string_parts))
+
+
+def _date_common_to_dto(expr_el: Element) -> CibRuleDateCommonDto:
+ return CibRuleDateCommonDto(
+ expr_el.get("id", ""), export_attributes(expr_el, with_id=False),
+ )
+
+
+def _date_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto:
+ date_spec = expr_el.find("./date_spec")
+ duration = expr_el.find("./duration")
+
+ string_parts = []
+ # "operation" is defined as mandatory in CIB schema
+ operation = expr_el.get("operation", "")
+ if operation == "date_spec":
+ string_parts.append("date-spec")
+ if date_spec is not None:
+ string_parts.append(_attrs_to_str(date_spec))
+ elif operation == "in_range":
+ string_parts.extend(["date", "in_range"])
+ # CIB schema allows "start" + "duration" or optional "start" + "end"
+ if "start" in expr_el.attrib:
+ string_parts.extend([expr_el.get("start", ""), "to"])
+ if "end" in expr_el.attrib:
+ string_parts.append(expr_el.get("end", ""))
+ if duration is not None:
+ string_parts.append("duration")
+ string_parts.append(_attrs_to_str(duration))
+ else:
+ # CIB schema allows operation=="gt" + "start" or operation=="lt" + "end"
+ string_parts.extend(["date", expr_el.get("operation", "")])
+ if "start" in expr_el.attrib:
+ string_parts.append(expr_el.get("start", ""))
+ if "end" in expr_el.attrib:
+ string_parts.append(expr_el.get("end", ""))
+
+ return CibRuleExpressionDto(
+ expr_el.get("id", ""),
+ _tag_to_type[expr_el.tag],
+ False,
+ export_attributes(expr_el, with_id=False),
+ None if date_spec is None else _date_common_to_dto(date_spec),
+ None if duration is None else _date_common_to_dto(duration),
+ [],
+ " ".join(string_parts),
+ )
+
+
+def _op_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto:
+ string_parts = ["op"]
+ string_parts.append(expr_el.get("name", ""))
+ if "interval" in expr_el.attrib:
+ string_parts.append(
+ "interval={interval}".format(interval=expr_el.get("interval", ""))
+ )
+ return _common_expr_to_dto(expr_el, " ".join(string_parts))
+
+
+def _rsc_expr_to_dto(expr_el: Element) -> CibRuleExpressionDto:
+ return _common_expr_to_dto(
+ expr_el,
+ (
+ "resource "
+ + ":".join(
+ [
+ expr_el.get(attr, "")
+ for attr in ["class", "provider", "type"]
+ ]
+ )
+ ),
+ )
+
+
+_tag_to_type = {
+ "rule": CibRuleExpressionType.RULE,
+ "expression": CibRuleExpressionType.EXPRESSION,
+ "date_expression": CibRuleExpressionType.DATE_EXPRESSION,
+ "op_expression": CibRuleExpressionType.OP_EXPRESSION,
+ "rsc_expression": CibRuleExpressionType.RSC_EXPRESSION,
+}
+
+_tag_to_export = {
+ "rule": _rule_to_dto,
+ "expression": _simple_expr_to_dto,
+ "date_expression": _date_expr_to_dto,
+ "op_expression": _op_expr_to_dto,
+ "rsc_expression": _rsc_expr_to_dto,
+}
+_xpath_for_export = "./*[{export_tags}]".format(
+ export_tags=" or ".join(f"self::{tag}" for tag in _tag_to_export)
+)
diff --git a/pcs/lib/cib/rule/expression_part.py b/pcs/lib/cib/rule/expression_part.py
new file mode 100644
index 00000000..3ba63aa2
--- /dev/null
+++ b/pcs/lib/cib/rule/expression_part.py
@@ -0,0 +1,49 @@
+"""
+Provides classes used as nodes of a semantic tree of a parsed rule expression.
+"""
+from dataclasses import dataclass
+from typing import (
+ NewType,
+ Optional,
+ Sequence,
+)
+
+
+class RuleExprPart:
+ pass
+
+
+BoolOperator = NewType("BoolOperator", str)
+BOOL_AND = BoolOperator("AND")
+BOOL_OR = BoolOperator("OR")
+
+
+@dataclass(frozen=True)
+class BoolExpr(RuleExprPart):
+ """
+ Represents a rule combining RuleExprPart objects by AND or OR operation.
+ """
+
+ operator: BoolOperator
+ children: Sequence[RuleExprPart]
+
+
+@dataclass(frozen=True)
+class RscExpr(RuleExprPart):
+ """
+ Represents a resource expression in a rule.
+ """
+
+ standard: Optional[str]
+ provider: Optional[str]
+ type: Optional[str]
+
+
+@dataclass(frozen=True)
+class OpExpr(RuleExprPart):
+ """
+ Represents an op expression in a rule.
+ """
+
+ name: str
+ interval: Optional[str]
diff --git a/pcs/lib/cib/rule/parsed_to_cib.py b/pcs/lib/cib/rule/parsed_to_cib.py
new file mode 100644
index 00000000..0fcae4f1
--- /dev/null
+++ b/pcs/lib/cib/rule/parsed_to_cib.py
@@ -0,0 +1,103 @@
+from typing import cast
+from xml.etree.ElementTree import Element
+
+from lxml import etree
+from lxml.etree import _Element
+
+from pcs.lib.cib.tools import (
+ IdProvider,
+ create_subelement_id,
+)
+
+from .expression_part import (
+ BoolExpr,
+ OpExpr,
+ RscExpr,
+ RuleExprPart,
+)
+
+
+def export(
+ parent_el: Element, id_provider: IdProvider, expr_tree: BoolExpr,
+) -> Element:
+ """
+ Export parsed rule to a CIB element
+
+ parent_el -- element to place the rule into
+ id_provider -- elements' ids generator
+ expr_tree -- parsed rule tree root
+ """
+ element = __export_part(parent_el, expr_tree, id_provider)
+ # Add score only to the top level rule element (which is represented by
+ # BoolExpr class). This is achieved by this function not being called for
+ # child nodes.
+ # TODO This was implemented originaly only for rules in resource and
+ # operation defaults. In those cases, score is the only rule attribute and
+ # it is always INFINITY. Once this code is used for other rules, modify
+ # this behavior as needed.
+ if isinstance(expr_tree, BoolExpr):
+ element.set("score", "INFINITY")
+ return element
+
+
+def __export_part(
+ parent_el: Element, expr_tree: RuleExprPart, id_provider: IdProvider
+) -> Element:
+ part_export_map = {
+ BoolExpr: __export_bool,
+ OpExpr: __export_op,
+ RscExpr: __export_rsc,
+ }
+ func = part_export_map[type(expr_tree)]
+ # mypy doesn't handle this dynamic call
+ return func(parent_el, expr_tree, id_provider) # type: ignore
+
+
+def __export_bool(
+ parent_el: Element, boolean: BoolExpr, id_provider: IdProvider
+) -> Element:
+ element = etree.SubElement(
+ cast(_Element, parent_el),
+ "rule",
+ {
+ "id": create_subelement_id(parent_el, "rule", id_provider),
+ "boolean-op": boolean.operator.lower(),
+ },
+ )
+ for child in boolean.children:
+ __export_part(cast(Element, element), child, id_provider)
+ return cast(Element, element)
+
+
+def __export_op(
+ parent_el: Element, op: OpExpr, id_provider: IdProvider
+) -> Element:
+ element = etree.SubElement(
+ cast(_Element, parent_el),
+ "op_expression",
+ {
+ "id": create_subelement_id(parent_el, f"op-{op.name}", id_provider),
+ "name": op.name,
+ },
+ )
+ if op.interval:
+ element.attrib["interval"] = op.interval
+ return cast(Element, element)
+
+
+def __export_rsc(
+ parent_el: Element, rsc: RscExpr, id_provider: IdProvider
+) -> Element:
+ id_part = "-".join(filter(None, [rsc.standard, rsc.provider, rsc.type]))
+ element = etree.SubElement(
+ cast(_Element, parent_el),
+ "rsc_expression",
+ {"id": create_subelement_id(parent_el, f"rsc-{id_part}", id_provider)},
+ )
+ if rsc.standard:
+ element.attrib["class"] = rsc.standard
+ if rsc.provider:
+ element.attrib["provider"] = rsc.provider
+ if rsc.type:
+ element.attrib["type"] = rsc.type
+ return cast(Element, element)
diff --git a/pcs/lib/cib/rule/parser.py b/pcs/lib/cib/rule/parser.py
new file mode 100644
index 00000000..2215c524
--- /dev/null
+++ b/pcs/lib/cib/rule/parser.py
@@ -0,0 +1,232 @@
+from typing import (
+ Any,
+ Iterator,
+ Optional,
+ Tuple,
+)
+
+import pyparsing
+
+from .expression_part import (
+ BOOL_AND,
+ BOOL_OR,
+ BoolExpr,
+ OpExpr,
+ RscExpr,
+ RuleExprPart,
+)
+
+pyparsing.ParserElement.enablePackrat()
+
+
+class RuleParseError(Exception):
+ def __init__(
+ self,
+ rule_string: str,
+ rule_line: str,
+ lineno: int,
+ colno: int,
+ pos: int,
+ msg: str,
+ ):
+ super().__init__()
+ self.rule_string = rule_string
+ self.rule_line = rule_line
+ self.lineno = lineno
+ self.colno = colno
+ self.pos = pos
+ self.msg = msg
+
+
+def parse_rule(
+ rule_string: str, allow_rsc_expr: bool = False, allow_op_expr: bool = False
+) -> BoolExpr:
+ """
+ Parse a rule string and return a corresponding semantic tree
+
+ rule_string -- the whole rule expression
+ allow_rsc_expr -- allow resource expressions in the rule
+ allow_op_expr -- allow resource operation expressions in the rule
+ """
+ if not rule_string:
+ return BoolExpr(BOOL_AND, [])
+
+ try:
+ parsed = __get_rule_parser(
+ allow_rsc_expr=allow_rsc_expr, allow_op_expr=allow_op_expr
+ ).parseString(rule_string, parseAll=True)[0]
+ except pyparsing.ParseException as e:
+ raise RuleParseError(
+ rule_string, e.line, e.lineno, e.col, e.loc, e.args[2],
+ )
+
+ if not isinstance(parsed, BoolExpr):
+ # If we only got a representation on an inner rule element instead of a
+ # rule element itself, wrap the result in a default AND-rule. (There is
+ # only one expression so "and" vs. "or" doesn't really matter.)
+ parsed = BoolExpr(BOOL_AND, [parsed])
+
+ return parsed
+
+
+def __operator_operands(
+ token_list: pyparsing.ParseResults,
+) -> Iterator[Tuple[Any, Any]]:
+ # See pyparsing examples
+ # https://github.com/pyparsing/pyparsing/blob/master/examples/eval_arith.py
+ token_iterator = iter(token_list)
+ while True:
+ try:
+ yield (next(token_iterator), next(token_iterator))
+ except StopIteration:
+ break
+
+
+def __build_bool_tree(token_list: pyparsing.ParseResults) -> RuleExprPart:
+ # See pyparsing examples
+ # https://github.com/pyparsing/pyparsing/blob/master/examples/eval_arith.py
+ token_to_operator = {
+ "and": BOOL_AND,
+ "or": BOOL_OR,
+ }
+ operand_left = token_list[0][0]
+ last_operator: Optional[str] = None
+ operand_list = []
+ for operator, operand_right in __operator_operands(token_list[0][1:]):
+ # In each iteration, we get a bool_op ("and" or "or") and the right
+ # operand.
+ if last_operator == operator or last_operator is None:
+ # If we got the same operator as last time (or this is the first
+ # one), stack all the operads so we can put them all into one
+ # BoolExpr class.
+ operand_list.append(operand_right)
+ else:
+ # The operator has changed. Put all the stacked operands into the
+ # correct BoolExpr class and start the stacking again. The created
+ # class is the left operand of the current operator.
+ operand_left = BoolExpr(
+ token_to_operator[last_operator], [operand_left] + operand_list
+ )
+ operand_list = [operand_right]
+ last_operator = operator
+ if operand_list and last_operator:
+ # Use any of the remaining stacked operands.
+ operand_left = BoolExpr(
+ token_to_operator[last_operator], [operand_left] + operand_list
+ )
+ return operand_left
+
+
+def __build_op_expr(parse_result: pyparsing.ParseResults) -> RuleExprPart:
+ # Those attr are defined by setResultsName in op_expr grammar rule
+ return OpExpr(
+ parse_result.name,
+ # pyparsing-2.1.0 puts "interval_value" into parse_result.interval as
+ # defined in the grammar AND it also puts "interval_value" into
+ # parse_result. pyparsing-2.4.0 only puts "interval_value" into
+ # parse_result. Not sure why, maybe it's a bug, maybe it's intentional.
+ parse_result.interval_value if parse_result.interval_value else None,
+ )
+
+
+def __build_rsc_expr(parse_result: pyparsing.ParseResults) -> RuleExprPart:
+ # Those attrs are defined by the regexp in rsc_expr grammar rule
+ return RscExpr(
+ parse_result.standard, parse_result.provider, parse_result.type
+ )
+
+
+def __get_rule_parser(
+ allow_rsc_expr: bool = False, allow_op_expr: bool = False
+) -> pyparsing.ParserElement:
+ # This function defines the rule grammar
+
+ # It was created for 'pcs resource [op] defaults' commands to be able to
+ # set defaults for specified resources and/or operation using rules. When
+ # implementing that feature, there was no time to reimplement all the other
+ # rule expressions from old code. The plan is to move old rule parser code
+ # here once there is time / need to do it.
+ # How to add other rule expressions:
+ # 1 Create new grammar rules in a way similar to existing rsc_expr and
+ # op_expr. Use setName for better description of a grammar when printed.
+ # Use setResultsName for an easy access to parsed parts.
+ # 2 Create new classes in expression_part module, probably one for each
+ # type of expression. Those are data containers holding the parsed data
+ # independent of the parser.
+ # 3 Create builders for the new classes and connect them to created
+ # grammar rules using setParseAction.
+ # 4 Add the new expressions into simple_expr_list.
+ # 5 Test and debug the whole thing.
+
+ rsc_expr = pyparsing.And(
+ [
+ pyparsing.CaselessKeyword("resource"),
+ # resource name
+ # Up to three parts seperated by ":". The parts can contain any
+ # characters except whitespace (token separator), ":" (parts
+ # separator) and "()" (brackets).
+ pyparsing.Regex(
+ r"(?P<standard>[^\s:()]+)?:(?P<provider>[^\s:()]+)?:(?P<type>[^\s:()]+)?"
+ ).setName("<resource name>"),
+ ]
+ )
+ rsc_expr.setParseAction(__build_rsc_expr)
+
+ op_interval = pyparsing.And(
+ [
+ pyparsing.CaselessKeyword("interval"),
+ # no spaces allowed around the "="
+ pyparsing.Literal("=").leaveWhitespace(),
+ # interval value: number followed by a time unit, no spaces allowed
+ # between the number and the unit thanks to Combine being used
+ pyparsing.Combine(
+ pyparsing.And(
+ [
+ pyparsing.Word(pyparsing.nums),
+ pyparsing.Optional(pyparsing.Word(pyparsing.alphas)),
+ ]
+ )
+ )
+ .setName("<integer>[<time unit>]")
+ .setResultsName("interval_value"),
+ ]
+ )
+ op_expr = pyparsing.And(
+ [
+ pyparsing.CaselessKeyword("op"),
+ # operation name
+ # It can by any string containing any characters except whitespace
+ # (token separator) and "()" (brackets). Operations are defined in
+ # agents' metadata which we do not have access to (e.g. when the
+ # user sets operation "my_check" and doesn't even specify agent's
+ # name).
+ pyparsing.Regex(r"[^\s()]+")
+ .setName("<operation name>")
+ .setResultsName("name"),
+ pyparsing.Optional(op_interval).setResultsName("interval"),
+ ]
+ )
+ op_expr.setParseAction(__build_op_expr)
+
+ simple_expr_list = []
+ if allow_rsc_expr:
+ simple_expr_list.append(rsc_expr)
+ if allow_op_expr:
+ simple_expr_list.append(op_expr)
+ simple_expr = pyparsing.Or(simple_expr_list)
+
+ # See pyparsing examples
+ # https://github.com/pyparsing/pyparsing/blob/master/examples/simpleBool.py
+ # https://github.com/pyparsing/pyparsing/blob/master/examples/eval_arith.py
+ bool_operator = pyparsing.Or(
+ [pyparsing.CaselessKeyword("and"), pyparsing.CaselessKeyword("or")]
+ )
+ bool_expr = pyparsing.infixNotation(
+ simple_expr,
+ # By putting both "and" and "or" in one tuple we say they have the same
+ # priority. This is consistent with legacy pcs parsers. And it is how
+ # it should be, they work as a glue between "simple_expr"s.
+ [(bool_operator, 2, pyparsing.opAssoc.LEFT, __build_bool_tree)],
+ )
+
+ return pyparsing.Or([bool_expr, simple_expr])
diff --git a/pcs/lib/cib/rule/validator.py b/pcs/lib/cib/rule/validator.py
new file mode 100644
index 00000000..c733ad96
--- /dev/null
+++ b/pcs/lib/cib/rule/validator.py
@@ -0,0 +1,62 @@
+from typing import Set
+
+from pcs.common import reports
+from pcs.common.types import CibRuleExpressionType
+
+from .expression_part import (
+ BoolExpr,
+ OpExpr,
+ RscExpr,
+)
+
+
+class Validator:
+ # TODO For now we only check allowed expressions. Other checks and
+ # validations can be added if needed.
+ def __init__(
+ self,
+ parsed_rule: BoolExpr,
+ allow_rsc_expr: bool = False,
+ allow_op_expr: bool = False,
+ ):
+ """
+ parsed_rule -- a rule to be validated
+ allow_op_expr -- are op expressions allowed in the rule?
+ allow_rsc_expr -- are resource expressions allowed in the rule?
+ """
+ self._rule = parsed_rule
+ self._allow_op_expr = allow_op_expr
+ self._allow_rsc_expr = allow_rsc_expr
+ self._disallowed_expr_list: Set[CibRuleExpressionType] = set()
+
+ self._method_map = {
+ BoolExpr: self._validate_bool_expr,
+ OpExpr: self._validate_op_expr,
+ RscExpr: self._validate_rsc_expr,
+ }
+
+ def get_reports(self) -> reports.ReportItemList:
+ self._method_map[type(self._rule)](self._rule)
+ report_list = []
+ for expr_type in self._disallowed_expr_list:
+ report_list.append(
+ reports.ReportItem.error(
+ reports.messages.RuleExpressionNotAllowed(expr_type)
+ )
+ )
+ return report_list
+
+ def _validate_bool_expr(self, expr: BoolExpr):
+ for child in expr.children:
+ if type(child) in self._method_map:
+ self._method_map[type(child)](child)
+
+ def _validate_op_expr(self, expr):
+ del expr
+ if not self._allow_op_expr:
+ self._disallowed_expr_list.add(CibRuleExpressionType.OP_EXPRESSION)
+
+ def _validate_rsc_expr(self, expr):
+ del expr
+ if not self._allow_rsc_expr:
+ self._disallowed_expr_list.add(CibRuleExpressionType.RSC_EXPRESSION)
diff --git a/pcs/lib/cib/tools.py b/pcs/lib/cib/tools.py
index 920b7442..cfc5ba59 100644
--- a/pcs/lib/cib/tools.py
+++ b/pcs/lib/cib/tools.py
@@ -28,7 +28,7 @@ class IdProvider:
self._cib = get_root(cib_element)
self._booked_ids = set()
- def allocate_id(self, proposed_id):
+ def allocate_id(self, proposed_id: str) -> str:
"""
Generate a new unique id based on the proposal and keep track of it
string proposed_id -- requested id
@@ -294,9 +294,11 @@ def find_element_by_tag_and_id(
return None
-def create_subelement_id(context_element, suffix, id_provider):
+def create_subelement_id(
+ context_element: Element, suffix: str, id_provider: IdProvider
+) -> str:
proposed_id = sanitize_id(
- "{0}-{1}".format(context_element.get("id"), suffix)
+ "{0}-{1}".format(context_element.get("id", context_element.tag), suffix)
)
return id_provider.allocate_id(proposed_id)
diff --git a/pcs/lib/commands/cib_options.py b/pcs/lib/commands/cib_options.py
index 713644ca..368ce409 100644
--- a/pcs/lib/commands/cib_options.py
+++ b/pcs/lib/commands/cib_options.py
@@ -1,54 +1,312 @@
-from functools import partial
+from typing import (
+ Any,
+ Container,
+ Iterable,
+ List,
+ Mapping,
+ Optional,
+)
from pcs.common import reports
+from pcs.common.pacemaker.nvset import CibNvsetDto
from pcs.common.reports.item import ReportItem
-from pcs.lib.cib import sections
-from pcs.lib.cib.nvpair import arrange_first_meta_attributes
+from pcs.common.tools import Version
+from pcs.lib.cib import (
+ nvpair_multi,
+ sections,
+)
from pcs.lib.cib.tools import IdProvider
from pcs.lib.env import LibraryEnvironment
+from pcs.lib.errors import LibraryError
-def _set_any_defaults(section_name, env: LibraryEnvironment, options):
+def resource_defaults_create(
+ env: LibraryEnvironment,
+ nvpairs: Mapping[str, str],
+ nvset_options: Mapping[str, str],
+ nvset_rule: Optional[str] = None,
+ force_flags: Optional[Container] = None,
+) -> None:
"""
- string section_name -- determine the section of defaults
- env -- provides access to outside environment
- dict options -- are desired options with its values; when value is empty the
- option have to be removed
+ Create new resource defaults nvset
+
+ env --
+ nvpairs -- name-value pairs to be put into the new nvset
+ nvset_options -- additional attributes of the created nvset
+ nvset_rule -- optional rule describing when the created nvset applies
+ force_flags -- list of flags codes
+ """
+ return _defaults_create(
+ env,
+ sections.RSC_DEFAULTS,
+ dict(rule_allows_rsc_expr=True, rule_allows_op_expr=False),
+ nvpairs,
+ nvset_options,
+ nvset_rule=nvset_rule,
+ force_flags=force_flags,
+ )
+
+
+def operation_defaults_create(
+ env: LibraryEnvironment,
+ nvpairs: Mapping[str, str],
+ nvset_options: Mapping[str, str],
+ nvset_rule: Optional[str] = None,
+ force_flags: Optional[Container] = None,
+) -> None:
+ """
+ Create new operation defaults nvset
+
+ env --
+ nvpairs -- name-value pairs to be put into the new nvset
+ nvset_options -- additional attributes of the created nvset
+ nvset_rule -- optional rule describing when the created nvset applies
+ force_flags -- list of flags codes
"""
- # Do not ever remove the nvset element, even if it is empty. There may be
- # ACLs set in pacemaker which allow "write" for nvpairs (adding, changing
- # and removing) but not nvsets. In such a case, removing the nvset would
- # cause the whole change to be rejected by pacemaker with a "permission
- # denied" message.
- # https://bugzilla.redhat.com/show_bug.cgi?id=1642514
+ return _defaults_create(
+ env,
+ sections.OP_DEFAULTS,
+ dict(rule_allows_rsc_expr=True, rule_allows_op_expr=True),
+ nvpairs,
+ nvset_options,
+ nvset_rule=nvset_rule,
+ force_flags=force_flags,
+ )
+
+
+def _defaults_create(
+ env: LibraryEnvironment,
+ cib_section_name: str,
+ validator_options: Mapping[str, Any],
+ nvpairs: Mapping[str, str],
+ nvset_options: Mapping[str, str],
+ nvset_rule: Optional[str] = None,
+ force_flags: Optional[Container] = None,
+) -> None:
+ if force_flags is None:
+ force_flags = set()
+ force = (reports.codes.FORCE in force_flags) or (
+ reports.codes.FORCE_OPTIONS in force_flags
+ )
+
+ required_cib_version = None
+ if nvset_rule:
+ required_cib_version = Version(3, 4, 0)
+ cib = env.get_cib(required_cib_version)
+ id_provider = IdProvider(cib)
+
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ id_provider,
+ nvpairs,
+ nvset_options,
+ nvset_rule=nvset_rule,
+ **validator_options,
+ )
+ if env.report_processor.report_list(
+ validator.validate(force_options=force)
+ ).has_errors:
+ raise LibraryError()
+
+ nvpair_multi.nvset_append_new(
+ sections.get(cib, cib_section_name),
+ id_provider,
+ nvpair_multi.NVSET_META,
+ nvpairs,
+ nvset_options,
+ nvset_rule=validator.get_parsed_rule(),
+ )
+
env.report_processor.report(
ReportItem.warning(reports.messages.DefaultsCanBeOverriden())
)
+ env.push_cib()
+
+
+def resource_defaults_config(env: LibraryEnvironment) -> List[CibNvsetDto]:
+ """
+ List all resource defaults nvsets
+ """
+ return _defaults_config(env, sections.RSC_DEFAULTS)
+
+
+def operation_defaults_config(env: LibraryEnvironment) -> List[CibNvsetDto]:
+ """
+ List all operation defaults nvsets
+ """
+ return _defaults_config(env, sections.OP_DEFAULTS)
+
+
+def _defaults_config(
+ env: LibraryEnvironment, cib_section_name: str,
+) -> List[CibNvsetDto]:
+ return [
+ nvpair_multi.nvset_element_to_dto(nvset_el)
+ for nvset_el in nvpair_multi.find_nvsets(
+ sections.get(env.get_cib(), cib_section_name)
+ )
+ ]
+
+
+def resource_defaults_remove(
+ env: LibraryEnvironment, nvset_id_list: Iterable[str]
+) -> None:
+ """
+ Remove specified resource defaults nvsets
+
+ env --
+ nvset_id_list -- nvset IDs to be removed
+ """
+ return _defaults_remove(env, sections.RSC_DEFAULTS, nvset_id_list)
+
+
+def operation_defaults_remove(
+ env: LibraryEnvironment, nvset_id_list: Iterable[str]
+) -> None:
+ """
+ Remove specified operation defaults nvsets
- if not options:
+ env --
+ nvset_id_list -- nvset IDs to be removed
+ """
+ return _defaults_remove(env, sections.OP_DEFAULTS, nvset_id_list)
+
+
+def _defaults_remove(
+ env: LibraryEnvironment, cib_section_name: str, nvset_id_list: Iterable[str]
+) -> None:
+ if not nvset_id_list:
return
+ nvset_elements, report_list = nvpair_multi.find_nvsets_by_ids(
+ sections.get(env.get_cib(), cib_section_name), nvset_id_list
+ )
+ if env.report_processor.report_list(report_list).has_errors:
+ raise LibraryError()
+ nvpair_multi.nvset_remove(nvset_elements)
+ env.push_cib()
+
+
+def resource_defaults_update(
+ env: LibraryEnvironment,
+ nvset_id: Optional[str],
+ nvpairs: Mapping[str, str],
+) -> None:
+ """
+ Update specified resource defaults nvset
+
+ env --
+ nvset_id -- nvset ID to be updated; if None, update an existing nvset if
+ there is only one
+ nvpairs -- name-value pairs to be put into the nvset
+ """
+ return _defaults_update(
+ env,
+ sections.RSC_DEFAULTS,
+ nvset_id,
+ nvpairs,
+ reports.const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE,
+ )
+
+def operation_defaults_update(
+ env: LibraryEnvironment,
+ nvset_id: Optional[str],
+ nvpairs: Mapping[str, str],
+) -> None:
+ """
+ Update specified operation defaults nvset
+
+ env --
+ nvset_id -- nvset ID to be updated; if None, update an existing nvset if
+ there is only one
+ nvpairs -- name-value pairs to be put into the nvset
+ """
+ return _defaults_update(
+ env,
+ sections.OP_DEFAULTS,
+ nvset_id,
+ nvpairs,
+ reports.const.PCS_COMMAND_OPERATION_DEFAULTS_UPDATE,
+ )
+
+
+def _defaults_update(
+ env: LibraryEnvironment,
+ cib_section_name: str,
+ nvset_id: Optional[str],
+ nvpairs: Mapping[str, str],
+ pcs_command: reports.types.PcsCommand,
+) -> None:
cib = env.get_cib()
+ id_provider = IdProvider(cib)
+
+ if nvset_id is None:
+ # Backward compatibility code to support an old use case where no id
+ # was requested and provided and the first meta_attributes nvset was
+ # created / updated. However, we check that there is only one nvset
+ # present in the CIB to prevent breaking the configuration with
+ # multiple nvsets in place.
+
+ # This is to be supported as it provides means of easily managing
+ # defaults if only one set of defaults is needed.
- # Do not create new defaults element if we are only removing values from it.
- only_removing = True
- for value in options.values():
- if value != "":
- only_removing = False
- break
- if only_removing and not sections.exists(cib, section_name):
+ # TODO move this to a separate lib command.
+
+ if not nvpairs:
+ return
+
+ # Do not create new defaults element if we are only removing values
+ # from it.
+ only_removing = True
+ for value in nvpairs.values():
+ if value != "":
+ only_removing = False
+ break
+ if only_removing and not sections.exists(cib, cib_section_name):
+ env.report_processor.report(
+ ReportItem.warning(reports.messages.DefaultsCanBeOverriden())
+ )
+ return
+
+ nvset_elements = nvpair_multi.find_nvsets(
+ sections.get(cib, cib_section_name)
+ )
+ if len(nvset_elements) > 1:
+ env.report_processor.report(
+ reports.item.ReportItem.error(
+ reports.messages.CibNvsetAmbiguousProvideNvsetId(
+ pcs_command
+ )
+ )
+ )
+ raise LibraryError()
+ env.report_processor.report(
+ ReportItem.warning(reports.messages.DefaultsCanBeOverriden())
+ )
+ if len(nvset_elements) == 1:
+ nvpair_multi.nvset_update(nvset_elements[0], id_provider, nvpairs)
+ elif only_removing:
+ # do not create new nvset if there is none and we are only removing
+ # nvpairs
+ return
+ else:
+ nvpair_multi.nvset_append_new(
+ sections.get(cib, cib_section_name),
+ id_provider,
+ nvpair_multi.NVSET_META,
+ nvpairs,
+ {},
+ )
+ env.push_cib()
return
- defaults_section = sections.get(cib, section_name)
- arrange_first_meta_attributes(
- defaults_section,
- options,
- IdProvider(cib),
- new_id="{0}-options".format(section_name),
+ nvset_elements, report_list = nvpair_multi.find_nvsets_by_ids(
+ sections.get(cib, cib_section_name), [nvset_id]
)
+ if env.report_processor.report_list(report_list).has_errors:
+ raise LibraryError()
+ nvpair_multi.nvset_update(nvset_elements[0], id_provider, nvpairs)
+ env.report_processor.report(
+ ReportItem.warning(reports.messages.DefaultsCanBeOverriden())
+ )
env.push_cib()
-
-
-set_operations_defaults = partial(_set_any_defaults, sections.OP_DEFAULTS)
-set_resources_defaults = partial(_set_any_defaults, sections.RSC_DEFAULTS)
diff --git a/pcs/lib/validate.py b/pcs/lib/validate.py
index 2edf8b31..7890585a 100644
--- a/pcs/lib/validate.py
+++ b/pcs/lib/validate.py
@@ -39,6 +39,7 @@ from pcs.common.reports import (
)
from pcs.lib.corosync import constants as corosync_constants
from pcs.lib.pacemaker.values import (
+ is_score,
timeout_to_seconds,
validate_id,
)
@@ -676,6 +677,20 @@ class ValuePositiveInteger(ValuePredicateBase):
return "a positive integer"
+class ValueScore(ValueValidator):
+ """
+ Report INVALID_SCORE if the value is not a valid CIB score
+ """
+
+ def _validate_value(self, value):
+ report_list = []
+ if not is_score(value.normalized):
+ report_list.append(
+ ReportItem.error(reports.messages.InvalidScore(value.original))
+ )
+ return report_list
+
+
class ValueTimeInterval(ValuePredicateBase):
"""
Report INVALID_OPTION_VALUE when the value is not a time interval
diff --git a/pcs/lib/xml_tools.py b/pcs/lib/xml_tools.py
index a463c418..b7d778a3 100644
--- a/pcs/lib/xml_tools.py
+++ b/pcs/lib/xml_tools.py
@@ -1,4 +1,4 @@
-from typing import cast, Iterable
+from typing import cast, Dict, Iterable
from xml.etree.ElementTree import Element
from lxml import etree
@@ -56,8 +56,11 @@ def get_sub_element(
return sub_element
-def export_attributes(element):
- return dict((key, value) for key, value in element.attrib.items())
+def export_attributes(element: Element, with_id: bool = True) -> Dict[str, str]:
+ result = dict((key, value) for key, value in element.attrib.items())
+ if not with_id:
+ result.pop("id", None)
+ return result
def update_attribute_remove_empty(element, name, value):
diff --git a/pcs/pcs.8 b/pcs/pcs.8
index 85c6adb1..c887d332 100644
--- a/pcs/pcs.8
+++ b/pcs/pcs.8
@@ -185,8 +185,48 @@ Remove specified operation (note: you must specify the exact operation propertie
op remove <operation id>
Remove the specified operation id.
.TP
-op defaults [options]
-Set default values for operations, if no options are passed, lists currently configured defaults. Defaults do not apply to resources which override them with their own defined operations.
+op defaults [config] [\fB\-\-full\fR]
+List currently configured default values for operations. If \fB\-\-full\fR is specified, also list ids.
+.TP
+op defaults <name>=<value>
+Set default values for operations.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
+.TP
+op defaults set create [<set options>] [meta [<name>=<value>]...] [rule [<expression>]]
+Create a new set of default values for resource operations. You may specify a rule describing resources and / or operations to which the set applies.
+.br
+Set options are: id, score
+.br
+Expression looks like one of the following:
+.br
+ op <operation name> [interval=<interval>]
+.br
+ resource [<standard>]:[<provider>]:[<type>]
+.br
+ <expression> and|or <expression>
+.br
+ ( <expression> )
+.br
+You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
+.TP
+op defaults set delete [<set id>]...
+Delete specified options sets.
+.TP
+op defaults set remove [<set id>]...
+Delete specified options sets.
+.TP
+op defaults set update <set id> [meta [<name>=<value>]...]
+Add, remove or change values in specified set of default values for resource operations.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
+.TP
+op defaults update <name>=<value>...
+Set default values for operations. This is a simplified command useful for cases when you only manage one set of default values.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
.TP
meta <resource id | group id | clone id> <meta options> [\fB\-\-wait\fR[=n]]
Add specified options to the specified resource, group or clone. Meta options should be in the format of name=value, options may be removed by setting an option without a value. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the changes to take effect and then return 0 if the changes have been processed or 1 otherwise. If 'n' is not specified it defaults to 60 minutes.
@@ -232,8 +272,46 @@ Set resources listed to managed mode (default). If \fB\-\-monitor\fR is specifie
unmanage <resource id | tag id>... [\fB\-\-monitor\fR]
Set resources listed to unmanaged mode. When a resource is in unmanaged mode, the cluster is not allowed to start nor stop the resource. If \fB\-\-monitor\fR is specified, disable all monitor operations of the resources.
.TP
-defaults [options]
-Set default values for resources, if no options are passed, lists currently configured defaults. Defaults do not apply to resources which override them with their own defined values.
+defaults [config] [\fB\-\-full\fR]
+List currently configured default values for resources. If \fB\-\-full\fR is specified, also list ids.
+.TP
+defaults <name>=<value>
+Set default values for resources.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
+.TP
+defaults set create [<set options>] [meta [<name>=<value>]...] [rule [<expression>]]
+Create a new set of default values for resources. You may specify a rule describing resources to which the set applies.
+.br
+Set options are: id, score
+.br
+Expression looks like one of the following:
+.br
+ resource [<standard>]:[<provider>]:[<type>]
+.br
+ <expression> and|or <expression>
+.br
+ ( <expression> )
+.br
+You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
+.TP
+defaults set delete [<set id>]...
+Delete specified options sets.
+.TP
+defaults set remove [<set id>]...
+Delete specified options sets.
+.TP
+defaults set update <set id> [meta [<name>=<value>]...]
+Add, remove or change values in specified set of default values for resources.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
+.TP
+defaults update <name>=<value>...
+Set default values for resources. This is a simplified command useful for cases when you only manage one set of default values.
+.br
+NOTE: Defaults do not apply to resources which override them with their own defined values.
.TP
cleanup [<resource id>] [node=<node>] [operation=<operation> [interval=<interval>]] [\fB\-\-strict\fR]
Make the cluster forget failed operations from history of the resource and re\-detect its current state. This can be useful to purge knowledge of past failures that have since been resolved.
diff --git a/pcs/resource.py b/pcs/resource.py
index dd199bea..e835fc99 100644
--- a/pcs/resource.py
+++ b/pcs/resource.py
@@ -6,7 +6,12 @@ import textwrap
import time
import json
-from typing import Any, List
+from typing import (
+ Any,
+ Callable,
+ List,
+ Sequence,
+)
from pcs import (
usage,
@@ -19,10 +24,12 @@ from pcs.settings import (
)
from pcs.cli.common.errors import CmdLineInputError, raise_command_replaced
from pcs.cli.common.parse_args import (
+ group_by_keywords,
prepare_options,
prepare_options_allowed,
InputModifiers,
)
+from pcs.cli.nvset import nvset_dto_list_to_lines
from pcs.cli.reports import process_library_reports
from pcs.cli.reports.output import error, warn
from pcs.cli.resource.parse_args import (
@@ -31,8 +38,8 @@ from pcs.cli.resource.parse_args import (
parse_bundle_update_options,
parse_create as parse_create_args,
)
+from pcs.common import reports
from pcs.common.str_tools import indent
-from pcs.common.reports import ReportItemSeverity
import pcs.lib.cib.acl as lib_acl
from pcs.lib.cib.resource import (
bundle,
@@ -113,28 +120,228 @@ def resource_utilization_cmd(lib, argv, modifiers):
set_resource_utilization(argv.pop(0), argv)
-def resource_defaults_cmd(lib, argv, modifiers):
+def _defaults_set_create_cmd(
+ lib_command: Callable[..., Any],
+ argv: Sequence[str],
+ modifiers: InputModifiers,
+):
+ modifiers.ensure_only_supported("-f", "--force")
+
+ groups = group_by_keywords(
+ argv,
+ set(["meta", "rule"]),
+ implicit_first_group_key="options",
+ keyword_repeat_allowed=False,
+ )
+ force_flags = set()
+ if modifiers.get("--force"):
+ force_flags.add(reports.codes.FORCE)
+
+ lib_command(
+ prepare_options(groups["meta"]),
+ prepare_options(groups["options"]),
+ nvset_rule=(" ".join(groups["rule"]) if groups["rule"] else None),
+ force_flags=force_flags,
+ )
+
+
+def resource_defaults_set_create_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --force - allow unknown options
+ """
+ return _defaults_set_create_cmd(
+ lib.cib_options.resource_defaults_create, argv, modifiers
+ )
+
+
+def resource_op_defaults_set_create_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --force - allow unknown options
+ """
+ return _defaults_set_create_cmd(
+ lib.cib_options.operation_defaults_create, argv, modifiers
+ )
+
+
+def _defaults_config_cmd(
+ lib_command: Callable[..., Any],
+ argv: Sequence[str],
+ modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --full - verbose output
+ """
+ if argv:
+ raise CmdLineInputError()
+ modifiers.ensure_only_supported("-f", "--full")
+ print(
+ "\n".join(
+ nvset_dto_list_to_lines(
+ lib_command(),
+ with_ids=modifiers.get("--full"),
+ text_if_empty="No defaults set",
+ )
+ )
+ )
+
+
+def resource_defaults_config_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --full - verbose output
+ """
+ return _defaults_config_cmd(
+ lib.cib_options.resource_defaults_config, argv, modifiers
+ )
+
+
+def resource_op_defaults_config_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ * --full - verbose output
+ """
+ return _defaults_config_cmd(
+ lib.cib_options.operation_defaults_config, argv, modifiers
+ )
+
+
+def _defaults_set_remove_cmd(
+ lib_command: Callable[..., Any],
+ argv: Sequence[str],
+ modifiers: InputModifiers,
+) -> None:
"""
Options:
* -f - CIB file
"""
modifiers.ensure_only_supported("-f")
- if not argv:
- print("\n".join(show_defaults(utils.get_cib_dom(), "rsc_defaults")))
- else:
- lib.cib_options.set_resources_defaults(prepare_options(argv))
+ lib_command(argv)
-def resource_op_defaults_cmd(lib, argv, modifiers):
+def resource_defaults_set_remove_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ """
+ return _defaults_set_remove_cmd(
+ lib.cib_options.resource_defaults_remove, argv, modifiers
+ )
+
+
+def resource_op_defaults_set_remove_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ """
+ return _defaults_set_remove_cmd(
+ lib.cib_options.operation_defaults_remove, argv, modifiers
+ )
+
+
+def _defaults_set_update_cmd(
+ lib_command: Callable[..., Any],
+ argv: Sequence[str],
+ modifiers: InputModifiers,
+) -> None:
"""
Options:
* -f - CIB file
"""
modifiers.ensure_only_supported("-f")
if not argv:
- print("\n".join(show_defaults(utils.get_cib_dom(), "op_defaults")))
- else:
- lib.cib_options.set_operations_defaults(prepare_options(argv))
+ raise CmdLineInputError()
+
+ set_id = argv[0]
+ groups = group_by_keywords(
+ argv[1:], set(["meta"]), keyword_repeat_allowed=False,
+ )
+ lib_command(
+ set_id, prepare_options(groups["meta"]),
+ )
+
+
+def resource_defaults_set_update_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ """
+ return _defaults_set_update_cmd(
+ lib.cib_options.resource_defaults_update, argv, modifiers
+ )
+
+
+def resource_op_defaults_set_update_cmd(
+ lib: Any, argv: Sequence[str], modifiers: InputModifiers,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ """
+ return _defaults_set_update_cmd(
+ lib.cib_options.operation_defaults_update, argv, modifiers
+ )
+
+
+def resource_defaults_legacy_cmd(
+ lib: Any,
+ argv: Sequence[str],
+ modifiers: InputModifiers,
+ deprecated_syntax_used: bool = False,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ """
+ del modifiers
+ if deprecated_syntax_used:
+ warn(
+ "This command is deprecated and will be removed. "
+ "Please use 'pcs resource defaults update' instead."
+ )
+ return lib.cib_options.resource_defaults_update(None, prepare_options(argv))
+
+
+def resource_op_defaults_legacy_cmd(
+ lib: Any,
+ argv: Sequence[str],
+ modifiers: InputModifiers,
+ deprecated_syntax_used: bool = False,
+) -> None:
+ """
+ Options:
+ * -f - CIB file
+ """
+ del modifiers
+ if deprecated_syntax_used:
+ warn(
+ "This command is deprecated and will be removed. "
+ "Please use 'pcs resource op defaults update' instead."
+ )
+ return lib.cib_options.operation_defaults_update(
+ None, prepare_options(argv)
+ )
def resource_op_add_cmd(lib, argv, modifiers):
@@ -741,9 +948,9 @@ def resource_update(lib, args, modifiers, deal_with_guest_change=True):
process_library_reports(report_list)
except lib_ra.ResourceAgentError as e:
severity = (
- ReportItemSeverity.WARNING
+ reports.ReportItemSeverity.WARNING
if modifiers.get("--force")
- else ReportItemSeverity.ERROR
+ else reports.ReportItemSeverity.ERROR
)
process_library_reports(
[lib_ra.resource_agent_error_to_report_item(e, severity)]
@@ -2543,30 +2750,6 @@ def resource_failcount_show(lib, resource, node, operation, interval, full):
return "\n".join(result_lines)
-def show_defaults(cib_dom, def_type):
- """
- Commandline options: no options
- """
- defs = cib_dom.getElementsByTagName(def_type)
- if not defs:
- return ["No defaults set"]
- defs = defs[0]
-
- # TODO duplicite to _nvpairs_strings
- key_val = {
- nvpair.getAttribute("name"): nvpair.getAttribute("value")
- for nvpair in defs.getElementsByTagName("nvpair")
- }
- if not key_val:
- return ["No defaults set"]
- strings = []
- for name, value in sorted(key_val.items()):
- if " " in value:
- value = f'"{value}"'
- strings.append(f"{name}={value}")
- return strings
-
-
def resource_node_lines(node):
"""
Commandline options: no options
@@ -2677,6 +2860,7 @@ def _nvpairs_strings(node, parent_tag, extra_vars_dict=None):
"""
Commandline options: no options
"""
+ # In the new architecture, this is implemented in pcs.cli.nvset.
key_val = {
nvpair.attrib["name"]: nvpair.attrib["value"]
for nvpair in node.findall(f"{parent_tag}/nvpair")
diff --git a/pcs/usage.py b/pcs/usage.py
index 2cab7a6c..8722bd7b 100644
--- a/pcs/usage.py
+++ b/pcs/usage.py
@@ -442,10 +442,50 @@ Commands:
op remove <operation id>
Remove the specified operation id.
- op defaults [options]
- Set default values for operations, if no options are passed, lists
- currently configured defaults. Defaults do not apply to resources which
- override them with their own defined operations.
+ op defaults [config] [--full]
+ List currently configured default values for operations. If --full is
+ specified, also list ids.
+
+ op defaults <name>=<value>...
+ Set default values for operations.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
+
+ op defaults set create [<set options>] [meta [<name>=<value>]...]
+ [rule [<expression>]]
+ Create a new set of default values for resource operations. You may
+ specify a rule describing resources and / or operations to which the set
+ applies.
+ Set options are: id, score
+ Expression looks like one of the following:
+ op <operation name> [interval=<interval>]
+ resource [<standard>]:[<provider>]:[<type>]
+ <expression> and|or <expression>
+ ( <expression> )
+ You may specify all or any of 'standard', 'provider' and 'type' in
+ a resource expression. For example: 'resource ocf::' matches all
+ resources of 'ocf' standard, while 'resource ::Dummy' matches all
+ resources of 'Dummy' type regardless of their standard and provider.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
+
+ op defaults set delete [<set id>]...
+ Delete specified options sets.
+
+ op defaults set remove [<set id>]...
+ Delete specified options sets.
+
+ op defaults set update <set id> [meta [<name>=<value>]...]
+ Add, remove or change values in specified set of default values for
+ resource operations.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
+
+ op defaults update <name>=<value>...
+ Set default values for operations. This is a simplified command useful
+ for cases when you only manage one set of default values.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
meta <resource id | group id | clone id> <meta options>
[--wait[=n]]
@@ -561,10 +601,48 @@ Commands:
--monitor is specified, disable all monitor operations of the
resources.
- defaults [options]
- Set default values for resources, if no options are passed, lists
- currently configured defaults. Defaults do not apply to resources which
- override them with their own defined values.
+ defaults [config] [--full]
+ List currently configured default values for resources. If --full is
+ specified, also list ids.
+
+ defaults <name>=<value>...
+ Set default values for resources.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
+
+ defaults set create [<set options>] [meta [<name>=<value>]...]
+ [rule [<expression>]]
+ Create a new set of default values for resources. You may specify a rule
+ describing resources to which the set applies.
+ Set options are: id, score
+ Expression looks like one of the following:
+ resource [<standard>]:[<provider>]:[<type>]
+ <expression> and|or <expression>
+ ( <expression> )
+ You may specify all or any of 'standard', 'provider' and 'type' in
+ a resource expression. For example: 'resource ocf::' matches all
+ resources of 'ocf' standard, while 'resource ::Dummy' matches all
+ resources of 'Dummy' type regardless of their standard and provider.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
+
+ defaults set delete [<set id>]...
+ Delete specified options sets.
+
+ defaults set remove [<set id>]...
+ Delete specified options sets.
+
+ defaults set update <set id> [meta [<name>=<value>]...]
+ Add, remove or change values in specified set of default values for
+ resources.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
+
+ defaults update <name>=<value>...
+ Set default values for resources. This is a simplified command useful
+ for cases when you only manage one set of default values.
+ NOTE: Defaults do not apply to resources which override them with their
+ own defined values.
cleanup [<resource id>] [node=<node>] [operation=<operation>
[interval=<interval>]] [--strict]
diff --git a/pcs_test/resources/cib-empty-3.1.xml b/pcs_test/resources/cib-empty-3.1.xml
index 75bbb26d..88f5c414 100644
--- a/pcs_test/resources/cib-empty-3.1.xml
+++ b/pcs_test/resources/cib-empty-3.1.xml
@@ -1,4 +1,4 @@
-<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.1" crm_feature_set="3.0.9" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.1" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
<configuration>
<crm_config/>
<nodes>
diff --git a/pcs_test/resources/cib-empty-3.2.xml b/pcs_test/resources/cib-empty-3.2.xml
index 0b0b04b8..7ffaccb1 100644
--- a/pcs_test/resources/cib-empty-3.2.xml
+++ b/pcs_test/resources/cib-empty-3.2.xml
@@ -1,4 +1,4 @@
-<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.2" crm_feature_set="3.0.9" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.2" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
<configuration>
<crm_config/>
<nodes>
diff --git a/pcs_test/resources/cib-empty-3.3.xml b/pcs_test/resources/cib-empty-3.3.xml
new file mode 100644
index 00000000..3a44fe08
--- /dev/null
+++ b/pcs_test/resources/cib-empty-3.3.xml
@@ -0,0 +1,10 @@
+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.3" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
+ <configuration>
+ <crm_config/>
+ <nodes>
+ </nodes>
+ <resources/>
+ <constraints/>
+ </configuration>
+ <status/>
+</cib>
diff --git a/pcs_test/resources/cib-empty-3.4.xml b/pcs_test/resources/cib-empty-3.4.xml
new file mode 100644
index 00000000..dcd4ff44
--- /dev/null
+++ b/pcs_test/resources/cib-empty-3.4.xml
@@ -0,0 +1,10 @@
+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.4" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
+ <configuration>
+ <crm_config/>
+ <nodes>
+ </nodes>
+ <resources/>
+ <constraints/>
+ </configuration>
+ <status/>
+</cib>
diff --git a/pcs_test/resources/cib-empty.xml b/pcs_test/resources/cib-empty.xml
index 75bbb26d..7ffaccb1 100644
--- a/pcs_test/resources/cib-empty.xml
+++ b/pcs_test/resources/cib-empty.xml
@@ -1,4 +1,4 @@
-<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.1" crm_feature_set="3.0.9" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
+<cib epoch="557" num_updates="122" admin_epoch="0" validate-with="pacemaker-3.2" crm_feature_set="3.1.0" update-origin="rh7-3" update-client="crmd" cib-last-written="Thu Aug 23 16:49:17 2012" have-quorum="0" dc-uuid="2">
<configuration>
<crm_config/>
<nodes>
diff --git a/pcs_test/tier0/cli/reports/test_messages.py b/pcs_test/tier0/cli/reports/test_messages.py
index 06f32e68..47aabd63 100644
--- a/pcs_test/tier0/cli/reports/test_messages.py
+++ b/pcs_test/tier0/cli/reports/test_messages.py
@@ -481,6 +481,35 @@ class TagCannotRemoveReferencesWithoutRemovingTag(CliReportMessageTestBase):
)
+class RuleExpressionParseError(CliReportMessageTestBase):
+ def test_success(self):
+ self.assert_message(
+ messages.RuleExpressionParseError(
+ "resource dummy op monitor",
+ "Expected end of text",
+ "resource dummy op monitor",
+ 1,
+ 16,
+ 15,
+ ),
+ "'resource dummy op monitor' is not a valid rule expression, "
+ "parse error near or after line 1 column 16\n"
+ " resource dummy op monitor\n"
+ " ---------------^",
+ )
+
+
+class CibNvsetAmbiguousProvideNvsetId(CliReportMessageTestBase):
+ def test_success(self):
+ self.assert_message(
+ messages.CibNvsetAmbiguousProvideNvsetId(
+ const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE
+ ),
+ "Several options sets exist, please use the 'pcs resource defaults "
+ "set update' command and specify an option set ID",
+ )
+
+
# TODO: create test/check that all subclasses of
# pcs.cli.reports.messages.CliReportMessageCustom have their test class with
# the same name in this file
diff --git a/pcs_test/tier0/cli/resource/test_defaults.py b/pcs_test/tier0/cli/resource/test_defaults.py
new file mode 100644
index 00000000..0582c664
--- /dev/null
+++ b/pcs_test/tier0/cli/resource/test_defaults.py
@@ -0,0 +1,324 @@
+from textwrap import dedent
+from unittest import mock, TestCase
+
+from pcs_test.tools.misc import dict_to_modifiers
+
+from pcs import resource
+from pcs.cli.common.errors import CmdLineInputError
+from pcs.common.pacemaker.nvset import (
+ CibNvpairDto,
+ CibNvsetDto,
+)
+from pcs.common.pacemaker.rule import CibRuleExpressionDto
+from pcs.common.reports import codes as report_codes
+from pcs.common.types import (
+ CibNvsetType,
+ CibRuleExpressionType,
+)
+
+
+class DefaultsBaseMixin:
+ cli_command_name = ""
+ lib_command_name = ""
+
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.lib = mock.Mock(spec_set=["cib_options"])
+ self.cib_options = mock.Mock(spec_set=[self.lib_command_name])
+ self.lib.cib_options = self.cib_options
+ self.lib_command = getattr(self.cib_options, self.lib_command_name)
+ self.cli_command = getattr(resource, self.cli_command_name)
+
+ def _call_cmd(self, argv, modifiers=None):
+ modifiers = modifiers or dict()
+ self.cli_command(self.lib, argv, dict_to_modifiers(modifiers))
+
+
+@mock.patch("pcs.resource.print")
+class DefaultsConfigMixin(DefaultsBaseMixin):
+ dto_list = [
+ CibNvsetDto(
+ "my-meta_attributes",
+ CibNvsetType.META,
+ {},
+ CibRuleExpressionDto(
+ "my-meta-rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "and", "score": "INFINITY"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-meta-rule-rsc",
+ CibRuleExpressionType.RSC_EXPRESSION,
+ False,
+ {
+ "class": "ocf",
+ "provider": "pacemaker",
+ "type": "Dummy",
+ },
+ None,
+ None,
+ [],
+ "resource ocf:pacemaker:Dummy",
+ ),
+ ],
+ "resource ocf:pacemaker:Dummy",
+ ),
+ [
+ CibNvpairDto("my-id-pair1", "name1", "value1"),
+ CibNvpairDto("my-id-pair2", "name2", "value2"),
+ ],
+ ),
+ CibNvsetDto(
+ "instance",
+ CibNvsetType.INSTANCE,
+ {},
+ None,
+ [CibNvpairDto("instance-pair", "inst", "ance")],
+ ),
+ CibNvsetDto(
+ "meta-plain",
+ CibNvsetType.META,
+ {"score": "123"},
+ None,
+ [CibNvpairDto("my-id-pair3", "name 1", "value 1")],
+ ),
+ ]
+
+ def test_no_args(self, mock_print):
+ self.lib_command.return_value = []
+ self._call_cmd([])
+ self.lib_command.assert_called_once_with()
+ mock_print.assert_called_once_with("No defaults set")
+
+ def test_usage(self, mock_print):
+ with self.assertRaises(CmdLineInputError) as cm:
+ self._call_cmd(["arg"])
+ self.assertIsNone(cm.exception.message)
+ self.lib_command.assert_not_called()
+ mock_print.assert_not_called()
+
+ def test_full(self, mock_print):
+ self.lib_command.return_value = []
+ self._call_cmd([], {"full": True})
+ self.lib_command.assert_called_once_with()
+ mock_print.assert_called_once_with("No defaults set")
+
+ def test_print(self, mock_print):
+ self.lib_command.return_value = self.dto_list
+ self._call_cmd([])
+ self.lib_command.assert_called_once_with()
+ mock_print.assert_called_once_with(
+ dedent(
+ '''\
+ Meta Attrs: my-meta_attributes
+ name1=value1
+ name2=value2
+ Rule: boolean-op=and score=INFINITY
+ Expression: resource ocf:pacemaker:Dummy
+ Attributes: instance
+ inst=ance
+ Meta Attrs: meta-plain score=123
+ "name 1"="value 1"'''
+ )
+ )
+
+ def test_print_full(self, mock_print):
+ self.lib_command.return_value = self.dto_list
+ self._call_cmd([], {"full": True})
+ self.lib_command.assert_called_once_with()
+ mock_print.assert_called_once_with(
+ dedent(
+ '''\
+ Meta Attrs: my-meta_attributes
+ name1=value1
+ name2=value2
+ Rule: boolean-op=and score=INFINITY (id:my-meta-rule)
+ Expression: resource ocf:pacemaker:Dummy (id:my-meta-rule-rsc)
+ Attributes: instance
+ inst=ance
+ Meta Attrs: meta-plain score=123
+ "name 1"="value 1"'''
+ )
+ )
+
+
+class RscDefaultsConfig(DefaultsConfigMixin, TestCase):
+ cli_command_name = "resource_defaults_config_cmd"
+ lib_command_name = "resource_defaults_config"
+
+
+class OpDefaultsConfig(DefaultsConfigMixin, TestCase):
+ cli_command_name = "resource_op_defaults_config_cmd"
+ lib_command_name = "operation_defaults_config"
+
+
+class DefaultsSetCreateMixin(DefaultsBaseMixin):
+ def test_no_args(self):
+ self._call_cmd([])
+ self.lib_command.assert_called_once_with(
+ {}, {}, nvset_rule=None, force_flags=set()
+ )
+
+ def test_no_values(self):
+ self._call_cmd(["meta", "rule"])
+ self.lib_command.assert_called_once_with(
+ {}, {}, nvset_rule=None, force_flags=set()
+ )
+
+ def test_bad_options_or_keyword(self):
+ with self.assertRaises(CmdLineInputError) as cm:
+ self._call_cmd(["aaa"])
+ self.assertEqual(
+ cm.exception.message, "missing value of 'aaa' option",
+ )
+ self.lib_command.assert_not_called()
+
+ def test_bad_values(self):
+ with self.assertRaises(CmdLineInputError) as cm:
+ self._call_cmd(["meta", "aaa"])
+ self.assertEqual(
+ cm.exception.message, "missing value of 'aaa' option",
+ )
+ self.lib_command.assert_not_called()
+
+ def test_options(self):
+ self._call_cmd(["id=custom-id", "score=10"])
+ self.lib_command.assert_called_once_with(
+ {},
+ {"id": "custom-id", "score": "10"},
+ nvset_rule=None,
+ force_flags=set(),
+ )
+
+ def test_nvpairs(self):
+ self._call_cmd(["meta", "name1=value1", "name2=value2"])
+ self.lib_command.assert_called_once_with(
+ {"name1": "value1", "name2": "value2"},
+ {},
+ nvset_rule=None,
+ force_flags=set(),
+ )
+
+ def test_rule(self):
+ self._call_cmd(["rule", "resource", "dummy", "or", "op", "monitor"])
+ self.lib_command.assert_called_once_with(
+ {},
+ {},
+ nvset_rule="resource dummy or op monitor",
+ force_flags=set(),
+ )
+
+ def test_force(self):
+ self._call_cmd([], {"force": True})
+ self.lib_command.assert_called_once_with(
+ {}, {}, nvset_rule=None, force_flags=set([report_codes.FORCE])
+ )
+
+ def test_all(self):
+ self._call_cmd(
+ [
+ "id=custom-id",
+ "score=10",
+ "meta",
+ "name1=value1",
+ "name2=value2",
+ "rule",
+ "resource",
+ "dummy",
+ "or",
+ "op",
+ "monitor",
+ ],
+ {"force": True},
+ )
+ self.lib_command.assert_called_once_with(
+ {"name1": "value1", "name2": "value2"},
+ {"id": "custom-id", "score": "10"},
+ nvset_rule="resource dummy or op monitor",
+ force_flags=set([report_codes.FORCE]),
+ )
+
+
+class RscDefaultsSetCreate(DefaultsSetCreateMixin, TestCase):
+ cli_command_name = "resource_defaults_set_create_cmd"
+ lib_command_name = "resource_defaults_create"
+
+
+class OpDefaultsSetCreate(DefaultsSetCreateMixin, TestCase):
+ cli_command_name = "resource_op_defaults_set_create_cmd"
+ lib_command_name = "operation_defaults_create"
+
+
+class DefaultsSetRemoveMixin(DefaultsBaseMixin):
+ def test_no_args(self):
+ self._call_cmd([])
+ self.lib_command.assert_called_once_with([])
+
+ def test_some_args(self):
+ self._call_cmd(["set1", "set2"])
+ self.lib_command.assert_called_once_with(["set1", "set2"])
+
+
+class RscDefaultsSetRemove(DefaultsSetRemoveMixin, TestCase):
+ cli_command_name = "resource_defaults_set_remove_cmd"
+ lib_command_name = "resource_defaults_remove"
+
+
+class OpDefaultsSetRemove(DefaultsSetRemoveMixin, TestCase):
+ cli_command_name = "resource_op_defaults_set_remove_cmd"
+ lib_command_name = "operation_defaults_remove"
+
+
+class DefaultsSetUpdateMixin(DefaultsBaseMixin):
+ def test_no_args(self):
+ with self.assertRaises(CmdLineInputError) as cm:
+ self._call_cmd([])
+ self.assertIsNone(cm.exception.message)
+ self.lib_command.assert_not_called()
+
+ def test_no_meta(self):
+ self._call_cmd(["nvset-id"])
+ self.lib_command.assert_called_once_with("nvset-id", {})
+
+ def test_no_meta_values(self):
+ self._call_cmd(["nvset-id", "meta"])
+ self.lib_command.assert_called_once_with("nvset-id", {})
+
+ def test_meta_values(self):
+ self._call_cmd(["nvset-id", "meta", "a=b", "c=d"])
+ self.lib_command.assert_called_once_with(
+ "nvset-id", {"a": "b", "c": "d"}
+ )
+
+
+class RscDefaultsSetUpdate(DefaultsSetUpdateMixin, TestCase):
+ cli_command_name = "resource_defaults_set_update_cmd"
+ lib_command_name = "resource_defaults_update"
+
+
+class OpDefaultsSetUpdate(DefaultsSetUpdateMixin, TestCase):
+ cli_command_name = "resource_op_defaults_set_update_cmd"
+ lib_command_name = "operation_defaults_update"
+
+
+class DefaultsUpdateMixin(DefaultsBaseMixin):
+ def test_no_args(self):
+ self._call_cmd([])
+ self.lib_command.assert_called_once_with(None, {})
+
+ def test_args(self):
+ self._call_cmd(["a=b", "c="])
+ self.lib_command.assert_called_once_with(None, {"a": "b", "c": ""})
+
+
+class RscDefaultsUpdate(DefaultsUpdateMixin, TestCase):
+ cli_command_name = "resource_defaults_legacy_cmd"
+ lib_command_name = "resource_defaults_update"
+
+
+class OpDefaultsUpdate(DefaultsUpdateMixin, TestCase):
+ cli_command_name = "resource_op_defaults_legacy_cmd"
+ lib_command_name = "operation_defaults_update"
diff --git a/pcs_test/tier0/cli/test_nvset.py b/pcs_test/tier0/cli/test_nvset.py
new file mode 100644
index 00000000..675d2899
--- /dev/null
+++ b/pcs_test/tier0/cli/test_nvset.py
@@ -0,0 +1,92 @@
+import re
+from textwrap import dedent
+from unittest import TestCase
+
+from pcs.cli import nvset
+from pcs.common.pacemaker.nvset import (
+ CibNvpairDto,
+ CibNvsetDto,
+)
+from pcs.common.pacemaker.rule import CibRuleExpressionDto
+from pcs.common.types import (
+ CibNvsetType,
+ CibRuleExpressionType,
+)
+
+
+class NvsetDtoToLines(TestCase):
+ type_to_label = (
+ (CibNvsetType.META, "Meta Attrs"),
+ (CibNvsetType.INSTANCE, "Attributes"),
+ )
+
+ @staticmethod
+ def _export(dto, with_ids):
+ return (
+ "\n".join(nvset.nvset_dto_to_lines(dto, with_ids=with_ids)) + "\n"
+ )
+
+ def assert_lines(self, dto, lines):
+ self.assertEqual(
+ self._export(dto, True), lines,
+ )
+ self.assertEqual(
+ self._export(dto, False), re.sub(r" +\(id:.*\)", "", lines),
+ )
+
+ def test_minimal(self):
+ for nvtype, label in self.type_to_label:
+ with self.subTest(nvset_type=nvtype, lanel=label):
+ dto = CibNvsetDto("my-id", nvtype, {}, None, [])
+ output = dedent(
+ f"""\
+ {label}: my-id
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_full(self):
+ for nvtype, label in self.type_to_label:
+ with self.subTest(nvset_type=nvtype, lanel=label):
+ dto = CibNvsetDto(
+ "my-id",
+ nvtype,
+ {"score": "150"},
+ CibRuleExpressionDto(
+ "my-id-rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "or"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-rule-op",
+ CibRuleExpressionType.OP_EXPRESSION,
+ False,
+ {"name": "monitor"},
+ None,
+ None,
+ [],
+ "op monitor",
+ ),
+ ],
+ "op monitor",
+ ),
+ [
+ CibNvpairDto("my-id-pair1", "name1", "value1"),
+ CibNvpairDto("my-id-pair2", "name 2", "value 2"),
+ CibNvpairDto("my-id-pair3", "name=3", "value=3"),
+ ],
+ )
+ output = dedent(
+ f"""\
+ {label}: my-id score=150
+ "name 2"="value 2"
+ name1=value1
+ "name=3"="value=3"
+ Rule: boolean-op=or (id:my-id-rule)
+ Expression: op monitor (id:my-id-rule-op)
+ """
+ )
+ self.assert_lines(dto, output)
diff --git a/pcs_test/tier0/cli/test_rule.py b/pcs_test/tier0/cli/test_rule.py
new file mode 100644
index 00000000..c3f6ddc4
--- /dev/null
+++ b/pcs_test/tier0/cli/test_rule.py
@@ -0,0 +1,477 @@
+import re
+from textwrap import dedent
+from unittest import TestCase
+
+from pcs.cli import rule
+from pcs.common.pacemaker.rule import (
+ CibRuleDateCommonDto,
+ CibRuleExpressionDto,
+)
+from pcs.common.types import CibRuleExpressionType
+
+
+class RuleDtoToLinesMixin:
+ @staticmethod
+ def _export(dto, with_ids):
+ return (
+ "\n".join(rule.rule_expression_dto_to_lines(dto, with_ids=with_ids))
+ + "\n"
+ )
+
+ def assert_lines(self, dto, lines):
+ self.assertEqual(
+ self._export(dto, True), lines,
+ )
+ self.assertEqual(
+ self._export(dto, False), re.sub(r" +\(id:.*\)", "", lines),
+ )
+
+
+class ExpressionDtoToLines(RuleDtoToLinesMixin, TestCase):
+ def test_defined(self):
+ dto = CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {"attribute": "pingd", "operation": "defined"},
+ None,
+ None,
+ [],
+ "defined pingd",
+ ),
+ ],
+ "defined pingd",
+ )
+ output = dedent(
+ """\
+ Rule: (id:my-id)
+ Expression: defined pingd (id:my-id-expr)
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_value_comparison(self):
+ dto = CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "my-attr",
+ "operation": "eq",
+ "value": "my value",
+ },
+ None,
+ None,
+ [],
+ "my-attr eq 'my value'",
+ ),
+ ],
+ "my-attr eq 'my value'",
+ )
+ output = dedent(
+ """\
+ Rule: (id:my-id)
+ Expression: my-attr eq 'my value' (id:my-id-expr)
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_value_comparison_with_type(self):
+ dto = CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "foo",
+ "operation": "gt",
+ "type": "version",
+ "value": "1.2.3",
+ },
+ None,
+ None,
+ [],
+ "foo gt version 1.2.3",
+ ),
+ ],
+ "foo gt version 1.2.3",
+ )
+ output = dedent(
+ """\
+ Rule: (id:my-id)
+ Expression: foo gt version 1.2.3 (id:my-id-expr)
+ """
+ )
+ self.assert_lines(dto, output)
+
+
+class DateExpressionDtoToLines(RuleDtoToLinesMixin, TestCase):
+ def test_simple(self):
+ dto = CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "gt", "start": "2014-06-26"},
+ None,
+ None,
+ [],
+ "date gt 2014-06-26",
+ ),
+ ],
+ "date gt 2014-06-26",
+ )
+ output = dedent(
+ """\
+ Rule: (id:rule)
+ Expression: date gt 2014-06-26 (id:rule-expr)
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_datespec(self):
+ dto = CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "date_spec"},
+ CibRuleDateCommonDto(
+ "rule-expr-datespec",
+ {"hours": "1-14", "monthdays": "20-30", "months": "1"},
+ ),
+ None,
+ [],
+ "date-spec hours=1-14 monthdays=20-30 months=1",
+ ),
+ ],
+ "date-spec hours=1-14 monthdays=20-30 months=1",
+ )
+ output = dedent(
+ """\
+ Rule: (id:rule)
+ Expression: (id:rule-expr)
+ Date Spec: hours=1-14 monthdays=20-30 months=1 (id:rule-expr-datespec)
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_inrange(self):
+ dto = CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {
+ "operation": "in_range",
+ "start": "2014-06-26",
+ "end": "2014-07-26",
+ },
+ None,
+ None,
+ [],
+ "date in_range 2014-06-26 to 2014-07-26",
+ ),
+ ],
+ "date in_range 2014-06-26 to 2014-07-26",
+ )
+ output = dedent(
+ """\
+ Rule: (id:rule)
+ Expression: date in_range 2014-06-26 to 2014-07-26 (id:rule-expr)
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_inrange_duration(self):
+ dto = CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "in_range", "start": "2014-06-26",},
+ None,
+ CibRuleDateCommonDto("rule-expr-duration", {"years": "1"}),
+ [],
+ "date in_range 2014-06-26 to duration years=1",
+ ),
+ ],
+ "date in_range 2014-06-26 to duration years=1",
+ )
+ output = dedent(
+ """\
+ Rule: (id:rule)
+ Expression: date in_range 2014-06-26 to duration (id:rule-expr)
+ Duration: years=1 (id:rule-expr-duration)
+ """
+ )
+ self.assert_lines(dto, output)
+
+
+class OpExpressionDtoToLines(RuleDtoToLinesMixin, TestCase):
+ def test_minimal(self):
+ dto = CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-op",
+ CibRuleExpressionType.OP_EXPRESSION,
+ False,
+ {"name": "start"},
+ None,
+ None,
+ [],
+ "op start",
+ ),
+ ],
+ "op start",
+ )
+ output = dedent(
+ """\
+ Rule: (id:my-id)
+ Expression: op start (id:my-id-op)
+ """
+ )
+ self.assert_lines(dto, output)
+
+ def test_interval(self):
+ dto = CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-op",
+ CibRuleExpressionType.OP_EXPRESSION,
+ False,
+ {"name": "start", "interval": "2min"},
+ None,
+ None,
+ [],
+ "op start interval=2min",
+ ),
+ ],
+ "op start interval=2min",
+ )
+ output = dedent(
+ """\
+ Rule: (id:my-id)
+ Expression: op start interval=2min (id:my-id-op)
+ """
+ )
+ self.assert_lines(dto, output)
+
+
+class ResourceExpressionDtoToLines(RuleDtoToLinesMixin, TestCase):
+ def test_success(self):
+ dto = CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.RSC_EXPRESSION,
+ False,
+ {"class": "ocf", "provider": "pacemaker", "type": "Dummy"},
+ None,
+ None,
+ [],
+ "resource ocf:pacemaker:Dummy",
+ ),
+ ],
+ "resource ocf:pacemaker:Dummy",
+ )
+ output = dedent(
+ """\
+ Rule: (id:my-id)
+ Expression: resource ocf:pacemaker:Dummy (id:my-id-expr)
+ """
+ )
+ self.assert_lines(dto, output)
+
+
+class RuleDtoToLines(RuleDtoToLinesMixin, TestCase):
+ def test_complex_rule(self):
+ dto = CibRuleExpressionDto(
+ "complex",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "or", "score": "INFINITY"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "complex-rule-1",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "and", "score": "0"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "complex-rule-1-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "date_spec"},
+ CibRuleDateCommonDto(
+ "complex-rule-1-expr-datespec",
+ {"hours": "12-23", "weekdays": "1-5"},
+ ),
+ None,
+ [],
+ "date-spec hours=12-23 weekdays=1-5",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule-1-expr-1",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "in_range", "start": "2014-07-26",},
+ None,
+ CibRuleDateCommonDto(
+ "complex-rule-1-expr-1-durat", {"months": "1"},
+ ),
+ [],
+ "date in_range 2014-07-26 to duration months=1",
+ ),
+ ],
+ "date-spec hours=12-23 weekdays=1-5 and date in_range "
+ "2014-07-26 to duration months=1",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "and", "score": "0"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "complex-rule-expr-1",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "foo",
+ "operation": "gt",
+ "type": "version",
+ "value": "1.2",
+ },
+ None,
+ None,
+ [],
+ "foo gt version 1.2",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "#uname",
+ "operation": "eq",
+ "value": "node3 4",
+ },
+ None,
+ None,
+ [],
+ "#uname eq 'node3 4'",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule-expr-2",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "#uname",
+ "operation": "eq",
+ "value": "nodeA",
+ },
+ None,
+ None,
+ [],
+ "#uname eq nodeA",
+ ),
+ ],
+ "foo gt version 1.2 and #uname eq 'node3 4' and #uname "
+ "eq nodeA",
+ ),
+ ],
+ "(date-spec hours=12-23 weekdays=1-5 and date in_range "
+ "2014-07-26 to duration months=1) or (foo gt version 1.2 and "
+ "#uname eq 'node3 4' and #uname eq nodeA)",
+ )
+ output = dedent(
+ """\
+ Rule: boolean-op=or score=INFINITY (id:complex)
+ Rule: boolean-op=and score=0 (id:complex-rule-1)
+ Expression: (id:complex-rule-1-expr)
+ Date Spec: hours=12-23 weekdays=1-5 (id:complex-rule-1-expr-datespec)
+ Expression: date in_range 2014-07-26 to duration (id:complex-rule-1-expr-1)
+ Duration: months=1 (id:complex-rule-1-expr-1-durat)
+ Rule: boolean-op=and score=0 (id:complex-rule)
+ Expression: foo gt version 1.2 (id:complex-rule-expr-1)
+ Expression: #uname eq 'node3 4' (id:complex-rule-expr)
+ Expression: #uname eq nodeA (id:complex-rule-expr-2)
+ """
+ )
+ self.assert_lines(dto, output)
diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py
index 2592bd40..fd217ffb 100644
--- a/pcs_test/tier0/common/reports/test_messages.py
+++ b/pcs_test/tier0/common/reports/test_messages.py
@@ -1,16 +1,17 @@
from unittest import TestCase
from pcs.common import file_type_codes
-from pcs.common.file import RawFileError
-from pcs.common.reports import (
- const,
- messages as reports,
-)
from pcs.common.fencing_topology import (
TARGET_TYPE_NODE,
TARGET_TYPE_REGEXP,
TARGET_TYPE_ATTRIBUTE,
)
+from pcs.common.file import RawFileError
+from pcs.common.reports import (
+ const,
+ messages as reports,
+)
+from pcs.common.types import CibRuleExpressionType
# pylint: disable=too-many-lines
@@ -4653,3 +4654,47 @@ class TagIdsNotInTheTag(NameBuildTest):
"Tag 'tag-id' does not contain ids: 'a', 'b'",
reports.TagIdsNotInTheTag("tag-id", ["b", "a"]),
)
+
+
+class RuleExpressionParseError(NameBuildTest):
+ def test_success(self):
+ self.assert_message_from_report(
+ "'resource dummy op monitor' is not a valid rule expression, "
+ "parse error near or after line 1 column 16",
+ reports.RuleExpressionParseError(
+ "resource dummy op monitor",
+ "Expected end of text",
+ "resource dummy op monitor",
+ 1,
+ 16,
+ 15,
+ ),
+ )
+
+
+class RuleExpressionNotAllowed(NameBuildTest):
+ def test_op(self):
+ self.assert_message_from_report(
+ "Keyword 'op' cannot be used in a rule in this command",
+ reports.RuleExpressionNotAllowed(
+ CibRuleExpressionType.OP_EXPRESSION
+ ),
+ )
+
+ def test_rsc(self):
+ self.assert_message_from_report(
+ "Keyword 'resource' cannot be used in a rule in this command",
+ reports.RuleExpressionNotAllowed(
+ CibRuleExpressionType.RSC_EXPRESSION
+ ),
+ )
+
+
+class CibNvsetAmbiguousProvideNvsetId(NameBuildTest):
+ def test_success(self):
+ self.assert_message_from_report(
+ "Several options sets exist, please specify an option set ID",
+ reports.CibNvsetAmbiguousProvideNvsetId(
+ const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE
+ ),
+ )
diff --git a/pcs_test/tier0/common/test_str_tools.py b/pcs_test/tier0/common/test_str_tools.py
index c4753437..97c1d223 100644
--- a/pcs_test/tier0/common/test_str_tools.py
+++ b/pcs_test/tier0/common/test_str_tools.py
@@ -249,6 +249,39 @@ class FormatListCustomLastSeparatort(TestCase):
)
+class FormatNameValueList(TestCase):
+ def test_empty(self):
+ self.assertEqual([], tools.format_name_value_list([]))
+
+ def test_many(self):
+ self.assertEqual(
+ ["name1=value1", '"name=2"="value 2"', '"name 3"="value=3"'],
+ tools.format_name_value_list(
+ [
+ ("name1", "value1"),
+ ("name=2", "value 2"),
+ ("name 3", "value=3"),
+ ]
+ ),
+ )
+
+
+class Quote(TestCase):
+ def test_no_quote(self):
+ self.assertEqual("string", tools.quote("string", " "))
+ self.assertEqual("string", tools.quote("string", " ="))
+
+ def test_quote(self):
+ self.assertEqual('"str ing"', tools.quote("str ing", " ="))
+ self.assertEqual('"str=ing"', tools.quote("str=ing", " ="))
+
+ def test_alternative_quote(self):
+ self.assertEqual("""'st"r i"ng'""", tools.quote('st"r i"ng', " "))
+
+ def test_escape(self):
+ self.assertEqual('''"st\\"r i'ng"''', tools.quote("st\"r i'ng", " "))
+
+
class Transform(TestCase):
def test_transform(self):
self.assertEqual(
diff --git a/pcs_test/tier0/lib/commands/cib_options/__init__.py b/pcs_test/tier0/lib/cib/rule/__init__.py
similarity index 100%
rename from pcs_test/tier0/lib/commands/cib_options/__init__.py
rename to pcs_test/tier0/lib/cib/rule/__init__.py
diff --git a/pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py b/pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py
new file mode 100644
index 00000000..ce06c469
--- /dev/null
+++ b/pcs_test/tier0/lib/cib/rule/test_cib_to_dto.py
@@ -0,0 +1,593 @@
+from unittest import TestCase
+
+from lxml import etree
+
+from pcs.common.pacemaker.rule import (
+ CibRuleDateCommonDto,
+ CibRuleExpressionDto,
+)
+from pcs.common.types import CibRuleExpressionType
+from pcs.lib.cib.rule import rule_element_to_dto
+
+
+class ExpressionToDto(TestCase):
+ def test_defined(self):
+ xml = etree.fromstring(
+ """
+ <rule id="my-id">
+ <expression id="my-id-expr"
+ attribute="pingd" operation="defined"
+ />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {"attribute": "pingd", "operation": "defined"},
+ None,
+ None,
+ [],
+ "defined pingd",
+ ),
+ ],
+ "defined pingd",
+ ),
+ )
+
+ def test_value_comparison(self):
+ xml = etree.fromstring(
+ """
+ <rule id="my-id">
+ <expression id="my-id-expr"
+ attribute="my-attr" operation="eq" value="my value"
+ />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "my-attr",
+ "operation": "eq",
+ "value": "my value",
+ },
+ None,
+ None,
+ [],
+ 'my-attr eq "my value"',
+ ),
+ ],
+ 'my-attr eq "my value"',
+ ),
+ )
+
+ def test_value_comparison_with_type(self):
+ xml = etree.fromstring(
+ """
+ <rule id="my-id">
+ <expression id="my-id-expr"
+ attribute="foo" operation="gt" type="version" value="1.2.3"
+ />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "foo",
+ "operation": "gt",
+ "type": "version",
+ "value": "1.2.3",
+ },
+ None,
+ None,
+ [],
+ "foo gt version 1.2.3",
+ ),
+ ],
+ "foo gt version 1.2.3",
+ ),
+ )
+
+
+class DateExpressionToDto(TestCase):
+ def test_gt(self):
+ xml = etree.fromstring(
+ """
+ <rule id="rule">
+ <date_expression id="rule-expr"
+ operation="gt" start="2014-06-26"
+ />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "gt", "start": "2014-06-26"},
+ None,
+ None,
+ [],
+ "date gt 2014-06-26",
+ ),
+ ],
+ "date gt 2014-06-26",
+ ),
+ )
+
+ def test_lt(self):
+ xml = etree.fromstring(
+ """
+ <rule id="rule">
+ <date_expression id="rule-expr"
+ operation="lt" end="2014-06-26"
+ />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "lt", "end": "2014-06-26"},
+ None,
+ None,
+ [],
+ "date lt 2014-06-26",
+ ),
+ ],
+ "date lt 2014-06-26",
+ ),
+ )
+
+ def test_datespec(self):
+ xml = etree.fromstring(
+ """
+ <rule id="rule">
+ <date_expression id="rule-expr" operation="date_spec">
+ <date_spec id="rule-expr-datespec"
+ hours="1-14" monthdays="20-30" months="1"
+ />
+ </date_expression>
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "date_spec"},
+ CibRuleDateCommonDto(
+ "rule-expr-datespec",
+ {
+ "hours": "1-14",
+ "monthdays": "20-30",
+ "months": "1",
+ },
+ ),
+ None,
+ [],
+ "date-spec hours=1-14 monthdays=20-30 months=1",
+ ),
+ ],
+ "date-spec hours=1-14 monthdays=20-30 months=1",
+ ),
+ )
+
+ def test_inrange(self):
+ xml = etree.fromstring(
+ """
+ <rule id="rule">
+ <date_expression id="rule-expr"
+ operation="in_range" start="2014-06-26" end="2014-07-26"
+ />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {
+ "operation": "in_range",
+ "start": "2014-06-26",
+ "end": "2014-07-26",
+ },
+ None,
+ None,
+ [],
+ "date in_range 2014-06-26 to 2014-07-26",
+ ),
+ ],
+ "date in_range 2014-06-26 to 2014-07-26",
+ ),
+ )
+
+ def test_inrange_duration(self):
+ xml = etree.fromstring(
+ """
+ <rule id="rule">
+ <date_expression id="rule-expr"
+ operation="in_range" start="2014-06-26"
+ >
+ <duration id="rule-expr-duration" years="1"/>
+ </date_expression>
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "rule-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "in_range", "start": "2014-06-26",},
+ None,
+ CibRuleDateCommonDto(
+ "rule-expr-duration", {"years": "1"},
+ ),
+ [],
+ "date in_range 2014-06-26 to duration years=1",
+ ),
+ ],
+ "date in_range 2014-06-26 to duration years=1",
+ ),
+ )
+
+
+class OpExpressionToDto(TestCase):
+ def test_minimal(self):
+ xml = etree.fromstring(
+ """
+ <rule id="my-id">
+ <op_expression id="my-id-op" name="start" />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-op",
+ CibRuleExpressionType.OP_EXPRESSION,
+ False,
+ {"name": "start"},
+ None,
+ None,
+ [],
+ "op start",
+ ),
+ ],
+ "op start",
+ ),
+ )
+
+ def test_interval(self):
+ xml = etree.fromstring(
+ """
+ <rule id="my-id">
+ <op_expression id="my-id-op" name="start" interval="2min" />
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-op",
+ CibRuleExpressionType.OP_EXPRESSION,
+ False,
+ {"name": "start", "interval": "2min"},
+ None,
+ None,
+ [],
+ "op start interval=2min",
+ ),
+ ],
+ "op start interval=2min",
+ ),
+ )
+
+
+class ResourceExpressionToDto(TestCase):
+ def test_success(self):
+ test_data = [
+ # ((class, provider, type), output)
+ ((None, None, None), "::"),
+ (("ocf", None, None), "ocf::"),
+ ((None, "pacemaker", None), ":pacemaker:"),
+ ((None, None, "Dummy"), "::Dummy"),
+ (("ocf", "pacemaker", None), "ocf:pacemaker:"),
+ (("ocf", None, "Dummy"), "ocf::Dummy"),
+ ((None, "pacemaker", "Dummy"), ":pacemaker:Dummy"),
+ (("ocf", "pacemaker", "Dummy"), "ocf:pacemaker:Dummy"),
+ ]
+ for in_data, out_data in test_data:
+ with self.subTest(in_data=in_data):
+ attrs = {}
+ if in_data[0] is not None:
+ attrs["class"] = in_data[0]
+ if in_data[1] is not None:
+ attrs["provider"] = in_data[1]
+ if in_data[2] is not None:
+ attrs["type"] = in_data[2]
+ attrs_str = " ".join(
+ [f"{name}='{value}'" for name, value in attrs.items()]
+ )
+ xml = etree.fromstring(
+ f"""
+ <rule id="my-id">
+ <rsc_expression id="my-id-expr" {attrs_str}/>
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "my-id",
+ CibRuleExpressionType.RULE,
+ False,
+ {},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-expr",
+ CibRuleExpressionType.RSC_EXPRESSION,
+ False,
+ attrs,
+ None,
+ None,
+ [],
+ f"resource {out_data}",
+ ),
+ ],
+ f"resource {out_data}",
+ ),
+ )
+
+
+class RuleToDto(TestCase):
+ def test_complex_rule(self):
+ xml = etree.fromstring(
+ """
+ <rule id="complex" boolean-op="or" score="INFINITY">
+ <rule id="complex-rule-1" boolean-op="and" score="0">
+ <date_expression id="complex-rule-1-expr"
+ operation="date_spec"
+ >
+ <date_spec id="complex-rule-1-expr-datespec"
+ weekdays="1-5" hours="12-23"
+ />
+ </date_expression>
+ <date_expression id="complex-rule-1-expr-1"
+ operation="in_range" start="2014-07-26"
+ >
+ <duration id="complex-rule-1-expr-1-durat" months="1"/>
+ </date_expression>
+ </rule>
+ <rule id="complex-rule" boolean-op="and" score="0">
+ <expression id="complex-rule-expr-1"
+ attribute="foo" operation="gt" type="version" value="1.2"
+ />
+ <expression id="complex-rule-expr"
+ attribute="#uname" operation="eq" value="node3 4"
+ />
+ <expression id="complex-rule-expr-2"
+ attribute="#uname" operation="eq" value="nodeA"
+ />
+ </rule>
+ </rule>
+ """
+ )
+ self.assertEqual(
+ rule_element_to_dto(xml),
+ CibRuleExpressionDto(
+ "complex",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "or", "score": "INFINITY"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "complex-rule-1",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "and", "score": "0"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "complex-rule-1-expr",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {"operation": "date_spec"},
+ CibRuleDateCommonDto(
+ "complex-rule-1-expr-datespec",
+ {"hours": "12-23", "weekdays": "1-5"},
+ ),
+ None,
+ [],
+ "date-spec hours=12-23 weekdays=1-5",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule-1-expr-1",
+ CibRuleExpressionType.DATE_EXPRESSION,
+ False,
+ {
+ "operation": "in_range",
+ "start": "2014-07-26",
+ },
+ None,
+ CibRuleDateCommonDto(
+ "complex-rule-1-expr-1-durat",
+ {"months": "1"},
+ ),
+ [],
+ "date in_range 2014-07-26 to duration months=1",
+ ),
+ ],
+ "date-spec hours=12-23 weekdays=1-5 and date in_range "
+ "2014-07-26 to duration months=1",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "and", "score": "0"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "complex-rule-expr-1",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "foo",
+ "operation": "gt",
+ "type": "version",
+ "value": "1.2",
+ },
+ None,
+ None,
+ [],
+ "foo gt version 1.2",
+ ),
+ CibRuleExpressionDto(
+ "complex-rule-expr",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "#uname",
+ "operation": "eq",
+ "value": "node3 4",
+ },
+ None,
+ None,
+ [],
+ '#uname eq "node3 4"',
+ ),
+ CibRuleExpressionDto(
+ "complex-rule-expr-2",
+ CibRuleExpressionType.EXPRESSION,
+ False,
+ {
+ "attribute": "#uname",
+ "operation": "eq",
+ "value": "nodeA",
+ },
+ None,
+ None,
+ [],
+ "#uname eq nodeA",
+ ),
+ ],
+ 'foo gt version 1.2 and #uname eq "node3 4" and #uname '
+ "eq nodeA",
+ ),
+ ],
+ "(date-spec hours=12-23 weekdays=1-5 and date in_range "
+ "2014-07-26 to duration months=1) or (foo gt version 1.2 and "
+ '#uname eq "node3 4" and #uname eq nodeA)',
+ ),
+ )
diff --git a/pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py b/pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py
new file mode 100644
index 00000000..f61fce99
--- /dev/null
+++ b/pcs_test/tier0/lib/cib/rule/test_parsed_to_cib.py
@@ -0,0 +1,214 @@
+from unittest import TestCase
+
+from lxml import etree
+
+from pcs_test.tools.assertions import assert_xml_equal
+from pcs_test.tools.xml import etree_to_str
+
+from pcs.lib.cib import rule
+from pcs.lib.cib.rule.expression_part import (
+ BOOL_AND,
+ BOOL_OR,
+ BoolExpr,
+ OpExpr,
+ RscExpr,
+)
+from pcs.lib.cib.tools import IdProvider
+
+
+class Base(TestCase):
+ @staticmethod
+ def assert_cib(tree, expected_xml):
+ xml = etree.fromstring('<root id="X"/>')
+ rule.rule_to_cib(xml, IdProvider(xml), tree)
+ assert_xml_equal(
+ '<root id="X">' + expected_xml + "</root>", etree_to_str(xml)
+ )
+
+
+class SimpleBool(Base):
+ def test_no_children(self):
+ self.assert_cib(
+ BoolExpr(BOOL_AND, []),
+ """
+ <rule id="X-rule" boolean-op="and" score="INFINITY" />
+ """,
+ )
+
+ def test_one_child(self):
+ self.assert_cib(
+ BoolExpr(BOOL_AND, [OpExpr("start", None)]),
+ """
+ <rule id="X-rule" boolean-op="and" score="INFINITY">
+ <op_expression id="X-rule-op-start" name="start" />
+ </rule>
+ """,
+ )
+
+ def test_two_children(self):
+ operators = [
+ (BOOL_OR, "or"),
+ (BOOL_AND, "and"),
+ ]
+ for op_in, op_out in operators:
+ with self.subTest(op_in=op_in, op_out=op_out):
+ self.assert_cib(
+ BoolExpr(
+ op_in,
+ [
+ OpExpr("start", None),
+ RscExpr("systemd", None, "pcsd"),
+ ],
+ ),
+ f"""
+ <rule id="X-rule" boolean-op="{op_out}" score="INFINITY">
+ <op_expression id="X-rule-op-start" name="start" />
+ <rsc_expression id="X-rule-rsc-systemd-pcsd"
+ class="systemd" type="pcsd"
+ />
+ </rule>
+ """,
+ )
+
+
+class SimpleOp(Base):
+ def test_minimal(self):
+ self.assert_cib(
+ OpExpr("start", None),
+ """
+ <op_expression id="X-op-start" name="start" />
+ """,
+ )
+
+ def test_interval(self):
+ self.assert_cib(
+ OpExpr("monitor", "2min"),
+ """
+ <op_expression id="X-op-monitor" name="monitor"
+ interval="2min"
+ />
+ """,
+ )
+
+
+class SimpleRsc(Base):
+ def test_class(self):
+ self.assert_cib(
+ RscExpr("ocf", None, None),
+ """
+ <rsc_expression id="X-rsc-ocf" class="ocf" />
+ """,
+ )
+
+ def test_provider(self):
+ self.assert_cib(
+ RscExpr(None, "pacemaker", None),
+ """
+ <rsc_expression id="X-rsc-pacemaker" provider="pacemaker" />
+ """,
+ )
+
+ def type(self):
+ self.assert_cib(
+ RscExpr(None, None, "Dummy"),
+ """
+ <rsc_expression id="X-rsc-Dummy" type="Dummy" />
+ """,
+ )
+
+ def test_provider_type(self):
+ self.assert_cib(
+ RscExpr(None, "pacemaker", "Dummy"),
+ """
+ <rsc_expression id="X-rsc-pacemaker-Dummy"
+ provider="pacemaker" type="Dummy"
+ />
+ """,
+ )
+
+ def test_class_provider(self):
+ self.assert_cib(
+ RscExpr("ocf", "pacemaker", None),
+ """
+ <rsc_expression id="X-rsc-ocf-pacemaker"
+ class="ocf" provider="pacemaker"
+ />
+ """,
+ )
+
+ def test_class_type(self):
+ self.assert_cib(
+ RscExpr("systemd", None, "pcsd"),
+ """
+ <rsc_expression id="X-rsc-systemd-pcsd"
+ class="systemd" type="pcsd"
+ />
+ """,
+ )
+
+ def test_class_provider_type(self):
+ self.assert_cib(
+ RscExpr("ocf", "pacemaker", "Dummy"),
+ """
+ <rsc_expression id="X-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ """,
+ )
+
+
+class Complex(Base):
+ def test_expr_1(self):
+ self.assert_cib(
+ BoolExpr(
+ BOOL_AND,
+ [
+ BoolExpr(
+ BOOL_OR,
+ [
+ RscExpr("ocf", "pacemaker", "Dummy"),
+ OpExpr("start", None),
+ RscExpr("systemd", None, "pcsd"),
+ RscExpr("ocf", "heartbeat", "Dummy"),
+ ],
+ ),
+ BoolExpr(
+ BOOL_OR,
+ [
+ OpExpr("monitor", "30s"),
+ RscExpr("ocf", "pacemaker", "Dummy"),
+ OpExpr("start", None),
+ OpExpr("monitor", "2min"),
+ ],
+ ),
+ ],
+ ),
+ """
+ <rule id="X-rule" boolean-op="and" score="INFINITY">
+ <rule id="X-rule-rule" boolean-op="or">
+ <rsc_expression id="X-rule-rule-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ <op_expression id="X-rule-rule-op-start" name="start" />
+ <rsc_expression id="X-rule-rule-rsc-systemd-pcsd"
+ class="systemd" type="pcsd"
+ />
+ <rsc_expression id="X-rule-rule-rsc-ocf-heartbeat-Dummy"
+ class="ocf" provider="heartbeat" type="Dummy"
+ />
+ </rule>
+ <rule id="X-rule-rule-1" boolean-op="or">
+ <op_expression id="X-rule-rule-1-op-monitor"
+ name="monitor" interval="30s"
+ />
+ <rsc_expression id="X-rule-rule-1-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ <op_expression id="X-rule-rule-1-op-start" name="start" />
+ <op_expression id="X-rule-rule-1-op-monitor-1"
+ name="monitor" interval="2min"
+ />
+ </rule>
+ </rule>
+ """,
+ )
diff --git a/pcs_test/tier0/lib/cib/rule/test_parser.py b/pcs_test/tier0/lib/cib/rule/test_parser.py
new file mode 100644
index 00000000..110fc739
--- /dev/null
+++ b/pcs_test/tier0/lib/cib/rule/test_parser.py
@@ -0,0 +1,270 @@
+from dataclasses import fields
+from textwrap import dedent
+from unittest import TestCase
+
+from pcs.common.str_tools import indent
+from pcs.lib.cib import rule
+from pcs.lib.cib.rule.expression_part import BoolExpr
+
+
+def _parsed_to_str(parsed):
+ if isinstance(parsed, BoolExpr):
+ str_args = []
+ for arg in parsed.children:
+ str_args.extend(_parsed_to_str(arg).splitlines())
+ return "\n".join(
+ [f"{parsed.__class__.__name__} {parsed.operator}"]
+ + indent(str_args)
+ )
+
+ parts = [parsed.__class__.__name__]
+ for field in fields(parsed):
+ value = getattr(parsed, field.name)
+ if value is not None:
+ parts.append(f"{field.name}={value}")
+ return " ".join(parts)
+
+
+class Parser(TestCase):
+ def test_success_parse_to_tree(self):
+ test_data = [
+ ("", "BoolExpr AND"),
+ (
+ "resource ::",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr"""
+ ),
+ ),
+ (
+ "resource ::dummy",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr type=dummy"""
+ ),
+ ),
+ (
+ "resource ocf::",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr standard=ocf"""
+ ),
+ ),
+ (
+ "resource :pacemaker:",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr provider=pacemaker"""
+ ),
+ ),
+ (
+ "resource systemd::Dummy",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr standard=systemd type=Dummy"""
+ ),
+ ),
+ (
+ "resource ocf:pacemaker:",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr standard=ocf provider=pacemaker"""
+ ),
+ ),
+ (
+ "resource :pacemaker:Dummy",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr provider=pacemaker type=Dummy"""
+ ),
+ ),
+ (
+ "resource ocf:pacemaker:Dummy",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr standard=ocf provider=pacemaker type=Dummy"""
+ ),
+ ),
+ (
+ "op monitor",
+ dedent(
+ """\
+ BoolExpr AND
+ OpExpr name=monitor"""
+ ),
+ ),
+ (
+ "op monitor interval=10",
+ dedent(
+ """\
+ BoolExpr AND
+ OpExpr name=monitor interval=10"""
+ ),
+ ),
+ (
+ "resource ::dummy and op monitor",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr type=dummy
+ OpExpr name=monitor"""
+ ),
+ ),
+ (
+ "resource ::dummy or op monitor interval=15s",
+ dedent(
+ """\
+ BoolExpr OR
+ RscExpr type=dummy
+ OpExpr name=monitor interval=15s"""
+ ),
+ ),
+ (
+ "op monitor and resource ::dummy",
+ dedent(
+ """\
+ BoolExpr AND
+ OpExpr name=monitor
+ RscExpr type=dummy"""
+ ),
+ ),
+ (
+ "op monitor interval=5min or resource ::dummy",
+ dedent(
+ """\
+ BoolExpr OR
+ OpExpr name=monitor interval=5min
+ RscExpr type=dummy"""
+ ),
+ ),
+ (
+ "(resource ::dummy or resource ::delay) and op monitor",
+ dedent(
+ """\
+ BoolExpr AND
+ BoolExpr OR
+ RscExpr type=dummy
+ RscExpr type=delay
+ OpExpr name=monitor"""
+ ),
+ ),
+ (
+ "(op start and op stop) or resource ::dummy",
+ dedent(
+ """\
+ BoolExpr OR
+ BoolExpr AND
+ OpExpr name=start
+ OpExpr name=stop
+ RscExpr type=dummy"""
+ ),
+ ),
+ (
+ "op monitor or (resource ::dummy and resource ::delay)",
+ dedent(
+ """\
+ BoolExpr OR
+ OpExpr name=monitor
+ BoolExpr AND
+ RscExpr type=dummy
+ RscExpr type=delay"""
+ ),
+ ),
+ (
+ "resource ::dummy and (op start or op stop)",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr type=dummy
+ BoolExpr OR
+ OpExpr name=start
+ OpExpr name=stop"""
+ ),
+ ),
+ (
+ "resource ::dummy and resource ::delay and op monitor",
+ dedent(
+ """\
+ BoolExpr AND
+ RscExpr type=dummy
+ RscExpr type=delay
+ OpExpr name=monitor"""
+ ),
+ ),
+ (
+ "resource ::rA or resource ::rB or resource ::rC and op monitor",
+ dedent(
+ """\
+ BoolExpr AND
+ BoolExpr OR
+ RscExpr type=rA
+ RscExpr type=rB
+ RscExpr type=rC
+ OpExpr name=monitor"""
+ ),
+ ),
+ (
+ "op start and op stop and op monitor or resource ::delay",
+ dedent(
+ """\
+ BoolExpr OR
+ BoolExpr AND
+ OpExpr name=start
+ OpExpr name=stop
+ OpExpr name=monitor
+ RscExpr type=delay"""
+ ),
+ ),
+ (
+ "(resource ::rA or resource ::rB or resource ::rC) and (op oX or op oY or op oZ)",
+ dedent(
+ """\
+ BoolExpr AND
+ BoolExpr OR
+ RscExpr type=rA
+ RscExpr type=rB
+ RscExpr type=rC
+ BoolExpr OR
+ OpExpr name=oX
+ OpExpr name=oY
+ OpExpr name=oZ"""
+ ),
+ ),
+ ]
+ for rule_string, rule_tree in test_data:
+ with self.subTest(rule_string=rule_string):
+ self.assertEqual(
+ rule_tree,
+ _parsed_to_str(
+ rule.parse_rule(
+ rule_string, allow_rsc_expr=True, allow_op_expr=True
+ )
+ ),
+ )
+
+ def test_not_valid_rule(self):
+ test_data = [
+ ("resource", (1, 9, 8, "Expected <resource name>")),
+ ("op", (1, 3, 2, "Expected <operation name>")),
+ ("resource ::rA and", (1, 15, 14, "Expected end of text")),
+ ("resource ::rA and op ", (1, 15, 14, "Expected end of text")),
+ ("resource ::rA and (", (1, 15, 14, "Expected end of text")),
+ ]
+
+ for rule_string, exception_data in test_data:
+ with self.subTest(rule_string=rule_string):
+ with self.assertRaises(rule.RuleParseError) as cm:
+ rule.parse_rule(
+ rule_string, allow_rsc_expr=True, allow_op_expr=True
+ )
+ e = cm.exception
+ self.assertEqual(exception_data, (e.lineno, e.colno, e.pos, e.msg))
+ self.assertEqual(rule_string, e.rule_string)
diff --git a/pcs_test/tier0/lib/cib/rule/test_validator.py b/pcs_test/tier0/lib/cib/rule/test_validator.py
new file mode 100644
index 00000000..95344a4a
--- /dev/null
+++ b/pcs_test/tier0/lib/cib/rule/test_validator.py
@@ -0,0 +1,68 @@
+from unittest import TestCase
+
+from pcs_test.tools import fixture
+from pcs_test.tools.assertions import assert_report_item_list_equal
+
+from pcs.common import reports
+from pcs.common.types import CibRuleExpressionType
+from pcs.lib.cib.rule.expression_part import (
+ BOOL_AND,
+ BOOL_OR,
+ BoolExpr,
+ OpExpr,
+ RscExpr,
+)
+from pcs.lib.cib.rule.validator import Validator
+
+
+class ValidatorTest(TestCase):
+ def setUp(self):
+ self.report_op = fixture.error(
+ reports.codes.RULE_EXPRESSION_NOT_ALLOWED,
+ expression_type=CibRuleExpressionType.OP_EXPRESSION,
+ )
+ self.report_rsc = fixture.error(
+ reports.codes.RULE_EXPRESSION_NOT_ALLOWED,
+ expression_type=CibRuleExpressionType.RSC_EXPRESSION,
+ )
+ self.rule_rsc = BoolExpr(
+ BOOL_OR, [RscExpr(None, None, "a"), RscExpr(None, None, "b")]
+ )
+ self.rule_op = BoolExpr(
+ BOOL_OR, [OpExpr("start", None), OpExpr("stop", None)]
+ )
+ self.rule = BoolExpr(BOOL_AND, [self.rule_rsc, self.rule_op])
+
+ def test_complex_rule(self):
+ test_data = (
+ (True, True, []),
+ (True, False, [self.report_rsc]),
+ (False, True, [self.report_op]),
+ (False, False, [self.report_rsc, self.report_op]),
+ )
+ for op_allowed, rsc_allowed, report_list in test_data:
+ with self.subTest(op_allowed=op_allowed, rsc_allowed=rsc_allowed):
+ assert_report_item_list_equal(
+ Validator(
+ self.rule,
+ allow_rsc_expr=rsc_allowed,
+ allow_op_expr=op_allowed,
+ ).get_reports(),
+ report_list,
+ )
+
+ def test_disallow_missing_op(self):
+ assert_report_item_list_equal(
+ Validator(
+ self.rule_rsc, allow_rsc_expr=True, allow_op_expr=False
+ ).get_reports(),
+ [],
+ )
+
+ def test_disallow_missing_rsc(self):
+ assert_report_item_list_equal(
+ Validator(
+ self.rule_op, allow_rsc_expr=False, allow_op_expr=True
+ ).get_reports(),
+ [],
+ )
diff --git a/pcs_test/tier0/lib/cib/test_nvpair_multi.py b/pcs_test/tier0/lib/cib/test_nvpair_multi.py
new file mode 100644
index 00000000..c68c7233
--- /dev/null
+++ b/pcs_test/tier0/lib/cib/test_nvpair_multi.py
@@ -0,0 +1,513 @@
+from unittest import TestCase
+
+from lxml import etree
+
+from pcs_test.tools import fixture
+from pcs_test.tools.assertions import (
+ assert_report_item_list_equal,
+ assert_xml_equal,
+)
+from pcs_test.tools.xml import etree_to_str
+
+from pcs.common import reports
+from pcs.common.pacemaker.nvset import (
+ CibNvpairDto,
+ CibNvsetDto,
+)
+from pcs.common.pacemaker.rule import CibRuleExpressionDto
+from pcs.common.types import (
+ CibNvsetType,
+ CibRuleExpressionType,
+)
+from pcs.lib.cib import nvpair_multi
+from pcs.lib.cib.rule.expression_part import (
+ BOOL_AND,
+ BoolExpr,
+ OpExpr,
+ RscExpr,
+)
+from pcs.lib.cib.tools import IdProvider
+
+
+class NvpairElementToDto(TestCase):
+ def test_success(self):
+ xml = etree.fromstring(
+ """
+ <nvpair id="my-id" name="my-name" value="my-value" />
+ """
+ )
+ self.assertEqual(
+ nvpair_multi.nvpair_element_to_dto(xml),
+ CibNvpairDto("my-id", "my-name", "my-value"),
+ )
+
+
+class NvsetElementToDto(TestCase):
+ tag_type = (
+ ("meta_attributes", CibNvsetType.META),
+ ("instance_attributes", CibNvsetType.INSTANCE),
+ )
+
+ def test_minimal(self):
+ for tag, nvtype in self.tag_type:
+ with self.subTest(tag=tag, nvset_type=nvtype):
+ xml = etree.fromstring(f"""<{tag} id="my-id" />""")
+ self.assertEqual(
+ nvpair_multi.nvset_element_to_dto(xml),
+ CibNvsetDto("my-id", nvtype, {}, None, []),
+ )
+
+ def test_full(self):
+ for tag, nvtype in self.tag_type:
+ with self.subTest(tag=tag, nvset_type=nvtype):
+ xml = etree.fromstring(
+ f"""
+ <{tag} id="my-id" score="150">
+ <rule id="my-id-rule" boolean-op="or">
+ <op_expression id="my-id-rule-op" name="monitor" />
+ </rule>
+ <nvpair id="my-id-pair1" name="name1" value="value1" />
+ <nvpair id="my-id-pair2" name="name2" value="value2" />
+ </{tag}>
+ """
+ )
+ self.assertEqual(
+ nvpair_multi.nvset_element_to_dto(xml),
+ CibNvsetDto(
+ "my-id",
+ nvtype,
+ {"score": "150"},
+ CibRuleExpressionDto(
+ "my-id-rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "or"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ "my-id-rule-op",
+ CibRuleExpressionType.OP_EXPRESSION,
+ False,
+ {"name": "monitor"},
+ None,
+ None,
+ [],
+ "op monitor",
+ ),
+ ],
+ "op monitor",
+ ),
+ [
+ CibNvpairDto("my-id-pair1", "name1", "value1"),
+ CibNvpairDto("my-id-pair2", "name2", "value2"),
+ ],
+ ),
+ )
+
+
+class FindNvsets(TestCase):
+ def test_empty(self):
+ xml = etree.fromstring("<parent />")
+ self.assertEqual([], nvpair_multi.find_nvsets(xml))
+
+ def test_full(self):
+ xml = etree.fromstring(
+ """
+ <parent>
+ <meta_attributes id="set1" />
+ <instance_attributes id="set2" />
+ <not_an_nvset id="set3" />
+ </parent>
+ """
+ )
+ self.assertEqual(
+ ["set1", "set2"],
+ [el.get("id") for el in nvpair_multi.find_nvsets(xml)],
+ )
+
+
+class FindNvsetsByIds(TestCase):
+ def test_success(self):
+ xml = etree.fromstring(
+ """
+ <parent>
+ <meta_attributes id="set1" />
+ <instance_attributes id="set2" />
+ <not_an_nvset id="set3" />
+ <meta_attributes id="set4" />
+ </parent>
+ """
+ )
+ element_list, report_list = nvpair_multi.find_nvsets_by_ids(
+ xml, ["set1", "set2", "set3", "setX"]
+ )
+ self.assertEqual(
+ ["set1", "set2"], [el.get("id") for el in element_list],
+ )
+ assert_report_item_list_equal(
+ report_list,
+ [
+ fixture.report_unexpected_element(
+ "set3", "not_an_nvset", ["options set"]
+ ),
+ fixture.report_not_found(
+ "setX",
+ context_type="parent",
+ expected_types=["options set"],
+ ),
+ ],
+ )
+
+
+class ValidateNvsetAppendNew(TestCase):
+ def setUp(self):
+ self.id_provider = IdProvider(
+ etree.fromstring("""<cib><tags><tag id="a" /></tags></cib>""")
+ )
+
+ def test_success_minimal(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider, {}, {}
+ )
+ assert_report_item_list_equal(
+ validator.validate(force_options=True), []
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+ def test_success_full(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider,
+ {"name": "value"},
+ {"id": "some-id", "score": "10"},
+ nvset_rule="resource ::stateful",
+ rule_allows_rsc_expr=True,
+ rule_allows_op_expr=True,
+ )
+ assert_report_item_list_equal(
+ validator.validate(), [],
+ )
+ self.assertEqual(
+ repr(validator.get_parsed_rule()),
+ "BoolExpr(operator='AND', children=["
+ "RscExpr(standard=None, provider=None, type='stateful')"
+ "])",
+ )
+
+ def test_id_not_valid(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider, {}, {"id": "123"}
+ )
+ assert_report_item_list_equal(
+ validator.validate(force_options=True),
+ [fixture.report_invalid_id("123", "1")],
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+ def test_id_not_available(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider, {}, {"id": "a"}
+ )
+ assert_report_item_list_equal(
+ validator.validate(force_options=True),
+ [fixture.error(reports.codes.ID_ALREADY_EXISTS, id="a")],
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+ def test_score_not_valid(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider, {}, {"score": "a"}
+ )
+ assert_report_item_list_equal(
+ validator.validate(force_options=True),
+ [fixture.error(reports.codes.INVALID_SCORE, score="a")],
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+ def test_options_names(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider, {}, {"not_valid": "a"}
+ )
+ assert_report_item_list_equal(
+ validator.validate(),
+ [
+ fixture.error(
+ reports.codes.INVALID_OPTIONS,
+ force_code=reports.codes.FORCE_OPTIONS,
+ option_names=["not_valid"],
+ allowed=["id", "score"],
+ option_type=None,
+ allowed_patterns=[],
+ ),
+ ],
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+ def test_options_names_forced(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider, {}, {"not_valid": "a"}
+ )
+ assert_report_item_list_equal(
+ validator.validate(force_options=True),
+ [
+ fixture.warn(
+ reports.codes.INVALID_OPTIONS,
+ option_names=["not_valid"],
+ allowed=["id", "score"],
+ option_type=None,
+ allowed_patterns=[],
+ ),
+ ],
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+ def test_rule_not_valid(self):
+ validator = nvpair_multi.ValidateNvsetAppendNew(
+ self.id_provider,
+ {},
+ {},
+ "bad rule",
+ rule_allows_rsc_expr=True,
+ rule_allows_op_expr=True,
+ )
+ assert_report_item_list_equal(
+ validator.validate(force_options=True),
+ [
+ fixture.error(
+ reports.codes.RULE_EXPRESSION_PARSE_ERROR,
+ rule_string="bad rule",
+ reason='Expected "resource"',
+ rule_line="bad rule",
+ line_number=1,
+ column_number=1,
+ position=0,
+ ),
+ ],
+ )
+ self.assertIsNone(validator.get_parsed_rule())
+
+
+class NvsetAppendNew(TestCase):
+ # pylint: disable=no-self-use
+ def test_minimal(self):
+ context_element = etree.fromstring("""<context id="a" />""")
+ id_provider = IdProvider(context_element)
+ nvpair_multi.nvset_append_new(
+ context_element, id_provider, nvpair_multi.NVSET_META, {}, {}
+ )
+ assert_xml_equal(
+ """
+ <context id="a">
+ <meta_attributes id="a-meta_attributes" />
+ </context>
+ """,
+ etree_to_str(context_element),
+ )
+
+ def test_nvpairs(self):
+ context_element = etree.fromstring("""<context id="a" />""")
+ id_provider = IdProvider(context_element)
+ nvpair_multi.nvset_append_new(
+ context_element,
+ id_provider,
+ nvpair_multi.NVSET_META,
+ {"attr1": "value1", "attr-empty": "", "attr2": "value2"},
+ {},
+ )
+ assert_xml_equal(
+ """
+ <context id="a">
+ <meta_attributes id="a-meta_attributes">
+ <nvpair id="a-meta_attributes-attr1"
+ name="attr1" value="value1"
+ />
+ <nvpair id="a-meta_attributes-attr2"
+ name="attr2" value="value2"
+ />
+ </meta_attributes>
+ </context>
+ """,
+ etree_to_str(context_element),
+ )
+
+ def test_rule(self):
+ context_element = etree.fromstring("""<context id="a" />""")
+ id_provider = IdProvider(context_element)
+ nvpair_multi.nvset_append_new(
+ context_element,
+ id_provider,
+ nvpair_multi.NVSET_META,
+ {},
+ {},
+ nvset_rule=BoolExpr(
+ BOOL_AND,
+ [RscExpr("ocf", "pacemaker", "Dummy"), OpExpr("start", None)],
+ ),
+ )
+ assert_xml_equal(
+ """
+ <context id="a">
+ <meta_attributes id="a-meta_attributes">
+ <rule id="a-meta_attributes-rule"
+ boolean-op="and" score="INFINITY"
+ >
+ <rsc_expression
+ id="a-meta_attributes-rule-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ <op_expression id="a-meta_attributes-rule-op-start"
+ name="start"
+ />
+ </rule>
+ </meta_attributes>
+ </context>
+ """,
+ etree_to_str(context_element),
+ )
+
+ def test_custom_id(self):
+ context_element = etree.fromstring("""<context id="a" />""")
+ id_provider = IdProvider(context_element)
+ nvpair_multi.nvset_append_new(
+ context_element,
+ id_provider,
+ nvpair_multi.NVSET_META,
+ {},
+ {"id": "custom-id"},
+ )
+ assert_xml_equal(
+ """
+ <context id="a">
+ <meta_attributes id="custom-id" />
+ </context>
+ """,
+ etree_to_str(context_element),
+ )
+
+ def test_options(self):
+ context_element = etree.fromstring("""<context id="a" />""")
+ id_provider = IdProvider(context_element)
+ nvpair_multi.nvset_append_new(
+ context_element,
+ id_provider,
+ nvpair_multi.NVSET_META,
+ {},
+ {"score": "INFINITY", "empty-attr": ""},
+ )
+ assert_xml_equal(
+ """
+ <context id="a">
+ <meta_attributes id="a-meta_attributes" score="INFINITY" />
+ </context>
+ """,
+ etree_to_str(context_element),
+ )
+
+ def test_everything(self):
+ context_element = etree.fromstring("""<context id="a" />""")
+ id_provider = IdProvider(context_element)
+ nvpair_multi.nvset_append_new(
+ context_element,
+ id_provider,
+ nvpair_multi.NVSET_META,
+ {"attr1": "value1", "attr-empty": "", "attr2": "value2"},
+ {"id": "custom-id", "score": "INFINITY", "empty-attr": ""},
+ nvset_rule=BoolExpr(
+ BOOL_AND,
+ [RscExpr("ocf", "pacemaker", "Dummy"), OpExpr("start", None)],
+ ),
+ )
+ assert_xml_equal(
+ """
+ <context id="a">
+ <meta_attributes id="custom-id" score="INFINITY">
+ <rule id="custom-id-rule"
+ boolean-op="and" score="INFINITY"
+ >
+ <rsc_expression id="custom-id-rule-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ <op_expression id="custom-id-rule-op-start"
+ name="start"
+ />
+ </rule>
+ <nvpair id="custom-id-attr1"
+ name="attr1" value="value1"
+ />
+ <nvpair id="custom-id-attr2"
+ name="attr2" value="value2"
+ />
+ </meta_attributes>
+ </context>
+ """,
+ etree_to_str(context_element),
+ )
+
+
+class NvsetRemove(TestCase):
+ # pylint: disable=no-self-use
+ def test_success(self):
+ xml = etree.fromstring(
+ """
+ <parent>
+ <meta_attributes id="set1" />
+ <instance_attributes id="set2" />
+ <not_an_nvset id="set3" />
+ <meta_attributes id="set4" />
+ </parent>
+ """
+ )
+ nvpair_multi.nvset_remove(
+ [xml.find(".//*[@id='set2']"), xml.find(".//*[@id='set4']")]
+ )
+ assert_xml_equal(
+ """
+ <parent>
+ <meta_attributes id="set1" />
+ <not_an_nvset id="set3" />
+ </parent>
+ """,
+ etree_to_str(xml),
+ )
+
+
+class NvsetUpdate(TestCase):
+ # pylint: disable=no-self-use
+ def test_success_nvpair_all_cases(self):
+ nvset_element = etree.fromstring(
+ """
+ <meta_attributes id="set1">
+ <nvpair id="pair1" name="name1" value="value1" />
+ <nvpair id="pair2" name="name2" value="value2" />
+ <nvpair id="pair3" name="name 3" value="value 3" />
+ <nvpair id="pair4" name="name4" value="value4" />
+ <nvpair id="pair4A" name="name4" value="value4A" />
+ <nvpair id="pair4B" name="name4" value="value4B" />
+ </meta_attributes>
+ """
+ )
+ id_provider = IdProvider(nvset_element)
+ nvpair_multi.nvset_update(
+ nvset_element,
+ id_provider,
+ {
+ "name2": "", # delete
+ "name 3": "value 3 new", # change and escaping spaces
+ "name4": "value4new", # change and make unique
+ "name5": "", # do not add empty
+ "name'6'": 'value"6"', # escaping
+ },
+ )
+ assert_xml_equal(
+ """
+ <meta_attributes id="set1">
+ <nvpair id="pair1" name="name1" value="value1" />
+ <nvpair id="pair3" name="name 3" value="value 3 new" />
+ <nvpair id="pair4" name="name4" value="value4new" />
+ <nvpair id="set1-name6"
+ name="name'6'" value="value"6""
+ />
+ </meta_attributes>
+ """,
+ etree_to_str(nvset_element),
+ )
diff --git a/pcs_test/tier0/lib/cib/test_tools.py b/pcs_test/tier0/lib/cib/test_tools.py
index 376012a1..56f29148 100644
--- a/pcs_test/tier0/lib/cib/test_tools.py
+++ b/pcs_test/tier0/lib/cib/test_tools.py
@@ -233,8 +233,8 @@ class FindUniqueIdTest(CibToolsTest):
)
-class CreateNvsetIdTest(TestCase):
- def test_create_plain_id_when_no_confilicting_id_there(self):
+class CreateSubelementId(TestCase):
+ def test_create_plain_id_when_no_conflicting_id_there(self):
context = etree.fromstring('<cib><a id="b"/></cib>')
self.assertEqual(
"b-name",
@@ -252,6 +252,15 @@ class CreateNvsetIdTest(TestCase):
),
)
+ def test_parent_has_no_id(self):
+ context = etree.fromstring("<cib><a/></cib>")
+ self.assertEqual(
+ "a-name",
+ lib.create_subelement_id(
+ context.find(".//a"), "name", lib.IdProvider(context)
+ ),
+ )
+
class GetConfigurationTest(CibToolsTest):
def test_success_if_exists(self):
diff --git a/pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py b/pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py
deleted file mode 100644
index 2542043a..00000000
--- a/pcs_test/tier0/lib/commands/cib_options/test_operations_defaults.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from unittest import TestCase
-
-from pcs_test.tools import fixture
-from pcs_test.tools.command_env import get_env_tools
-
-from pcs.common.reports import codes as report_codes
-from pcs.lib.commands import cib_options
-
-FIXTURE_INITIAL_DEFAULTS = """
- <op_defaults>
- <meta_attributes id="op_defaults-options">
- <nvpair id="op_defaults-options-a" name="a" value="b"/>
- <nvpair id="op_defaults-options-b" name="b" value="c"/>
- </meta_attributes>
- </op_defaults>
-"""
-
-
-class SetOperationsDefaults(TestCase):
- def setUp(self):
- self.env_assist, self.config = get_env_tools(test_case=self)
-
- def tearDown(self):
- self.env_assist.assert_reports(
- [fixture.warn(report_codes.DEFAULTS_CAN_BE_OVERRIDEN)]
- )
-
- def test_change(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(
- optional_in_conf="""
- <op_defaults>
- <meta_attributes id="op_defaults-options">
- <nvpair id="op_defaults-options-a" name="a" value="B"/>
- <nvpair id="op_defaults-options-b" name="b" value="C"/>
- </meta_attributes>
- </op_defaults>
- """
- )
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": "B", "b": "C",}
- )
-
- def test_add(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(
- optional_in_conf="""
- <op_defaults>
- <meta_attributes id="op_defaults-options">
- <nvpair id="op_defaults-options-a" name="a" value="b"/>
- <nvpair id="op_defaults-options-b" name="b" value="c"/>
- <nvpair id="op_defaults-options-c" name="c" value="d"/>
- </meta_attributes>
- </op_defaults>
- """
- )
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"c": "d"},
- )
-
- def test_remove(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(
- remove=(
- "./configuration/op_defaults/meta_attributes/nvpair[@name='a']"
- )
- )
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": ""},
- )
-
- def test_add_section_if_missing(self):
- self.config.runner.cib.load()
- self.config.env.push_cib(
- optional_in_conf="""
- <op_defaults>
- <meta_attributes id="op_defaults-options">
- <nvpair id="op_defaults-options-a" name="a" value="A"/>
- </meta_attributes>
- </op_defaults>
- """
- )
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": "A",}
- )
-
- def test_add_meta_if_missing(self):
- self.config.runner.cib.load(optional_in_conf="<op_defaults />")
- self.config.env.push_cib(
- optional_in_conf="""
- <op_defaults>
- <meta_attributes id="op_defaults-options">
- <nvpair id="op_defaults-options-a" name="a" value="A"/>
- </meta_attributes>
- </op_defaults>
- """
- )
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": "A",}
- )
-
- def test_dont_add_section_if_only_removing(self):
- self.config.runner.cib.load()
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": "", "b": "",}
- )
-
- def test_dont_add_meta_if_only_removing(self):
- self.config.runner.cib.load(optional_in_conf="<op_defaults />")
- self.config.env.push_cib(optional_in_conf="<op_defaults />")
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": "", "b": "",}
- )
-
- def test_keep_section_when_empty(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(remove="./configuration/op_defaults//nvpair")
- cib_options.set_operations_defaults(
- self.env_assist.get_env(), {"a": "", "b": "",}
- )
diff --git a/pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py b/pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py
deleted file mode 100644
index 51d8abf4..00000000
--- a/pcs_test/tier0/lib/commands/cib_options/test_resources_defaults.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from unittest import TestCase
-
-from pcs_test.tools import fixture
-from pcs_test.tools.command_env import get_env_tools
-
-from pcs.common.reports import codes as report_codes
-from pcs.lib.commands import cib_options
-
-FIXTURE_INITIAL_DEFAULTS = """
- <rsc_defaults>
- <meta_attributes id="rsc_defaults-options">
- <nvpair id="rsc_defaults-options-a" name="a" value="b"/>
- <nvpair id="rsc_defaults-options-b" name="b" value="c"/>
- </meta_attributes>
- </rsc_defaults>
-"""
-
-
-class SetResourcesDefaults(TestCase):
- def setUp(self):
- self.env_assist, self.config = get_env_tools(test_case=self)
-
- def tearDown(self):
- self.env_assist.assert_reports(
- [fixture.warn(report_codes.DEFAULTS_CAN_BE_OVERRIDEN)]
- )
-
- def test_change(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(
- optional_in_conf="""
- <rsc_defaults>
- <meta_attributes id="rsc_defaults-options">
- <nvpair id="rsc_defaults-options-a" name="a" value="B"/>
- <nvpair id="rsc_defaults-options-b" name="b" value="C"/>
- </meta_attributes>
- </rsc_defaults>
- """
- )
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": "B", "b": "C",}
- )
-
- def test_add(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(
- optional_in_conf="""
- <rsc_defaults>
- <meta_attributes id="rsc_defaults-options">
- <nvpair id="rsc_defaults-options-a" name="a" value="b"/>
- <nvpair id="rsc_defaults-options-b" name="b" value="c"/>
- <nvpair id="rsc_defaults-options-c" name="c" value="d"/>
- </meta_attributes>
- </rsc_defaults>
- """
- )
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"c": "d"},
- )
-
- def test_remove(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(
- remove=(
- "./configuration/rsc_defaults/meta_attributes/nvpair[@name='a']"
- )
- )
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": ""},
- )
-
- def test_add_section_if_missing(self):
- self.config.runner.cib.load()
- self.config.env.push_cib(
- optional_in_conf="""
- <rsc_defaults>
- <meta_attributes id="rsc_defaults-options">
- <nvpair id="rsc_defaults-options-a" name="a" value="A"/>
- </meta_attributes>
- </rsc_defaults>
- """
- )
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": "A",}
- )
-
- def test_add_meta_if_missing(self):
- self.config.runner.cib.load(optional_in_conf="<rsc_defaults />")
- self.config.env.push_cib(
- optional_in_conf="""
- <rsc_defaults>
- <meta_attributes id="rsc_defaults-options">
- <nvpair id="rsc_defaults-options-a" name="a" value="A"/>
- </meta_attributes>
- </rsc_defaults>
- """
- )
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": "A",}
- )
-
- def test_dont_add_section_if_only_removing(self):
- self.config.runner.cib.load()
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": "", "b": "",}
- )
-
- def test_dont_add_meta_if_only_removing(self):
- self.config.runner.cib.load(optional_in_conf="<rsc_defaults />")
- self.config.env.push_cib(optional_in_conf="<rsc_defaults />")
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": "", "b": "",}
- )
-
- def test_keep_section_when_empty(self):
- self.config.runner.cib.load(optional_in_conf=FIXTURE_INITIAL_DEFAULTS)
- self.config.env.push_cib(remove="./configuration/rsc_defaults//nvpair")
- cib_options.set_resources_defaults(
- self.env_assist.get_env(), {"a": "", "b": "",}
- )
diff --git a/pcs_test/tier0/lib/commands/test_cib_options.py b/pcs_test/tier0/lib/commands/test_cib_options.py
new file mode 100644
index 00000000..c7c8cb1f
--- /dev/null
+++ b/pcs_test/tier0/lib/commands/test_cib_options.py
@@ -0,0 +1,669 @@
+from unittest import TestCase
+
+from pcs_test.tools import fixture
+from pcs_test.tools.command_env import get_env_tools
+
+from pcs.common import reports
+from pcs.common.pacemaker.nvset import (
+ CibNvpairDto,
+ CibNvsetDto,
+)
+from pcs.common.pacemaker.rule import CibRuleExpressionDto
+from pcs.common.types import (
+ CibNvsetType,
+ CibRuleExpressionType,
+)
+from pcs.lib.commands import cib_options
+
+
+class DefaultsCreateMixin:
+ command = lambda *args, **kwargs: None
+ tag = ""
+
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.env_assist, self.config = get_env_tools(self)
+ self.config.runner.cib.load(filename="cib-empty-1.2.xml")
+
+ def test_success_minimal(self):
+ defaults_xml = f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes" />
+ </{self.tag}>
+ """
+ self.config.env.push_cib(optional_in_conf=defaults_xml)
+
+ self.command(self.env_assist.get_env(), {}, {})
+
+ self.env_assist.assert_reports(
+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)]
+ )
+
+ def test_success_one_set_already_there(self):
+ defaults_xml_1 = f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes" />
+ </{self.tag}>
+ """
+ defaults_xml_2 = f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes" />
+ <meta_attributes id="{self.tag}-meta_attributes-1" />
+ </{self.tag}>
+ """
+ self.config.runner.cib.load(
+ instead="runner.cib.load", optional_in_conf=defaults_xml_1
+ )
+ self.config.env.push_cib(optional_in_conf=defaults_xml_2)
+
+ self.command(self.env_assist.get_env(), {}, {})
+
+ self.env_assist.assert_reports(
+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)]
+ )
+
+ def test_success_cib_upgrade(self):
+ defaults_xml = f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes">
+ <rule id="{self.tag}-meta_attributes-rule"
+ boolean-op="and" score="INFINITY"
+ >
+ <rsc_expression
+ id="{self.tag}-meta_attributes-rule-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ </rule>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ self.config.runner.cib.load(
+ name="load_cib_old_version",
+ filename="cib-empty-3.3.xml",
+ before="runner.cib.load",
+ )
+ self.config.runner.cib.upgrade(before="runner.cib.load")
+ self.config.runner.cib.load(
+ filename="cib-empty-3.4.xml", instead="runner.cib.load"
+ )
+ self.config.env.push_cib(optional_in_conf=defaults_xml)
+
+ self.command(
+ self.env_assist.get_env(),
+ {},
+ {},
+ nvset_rule="resource ocf:pacemaker:Dummy",
+ )
+
+ self.env_assist.assert_reports(
+ [
+ fixture.info(reports.codes.CIB_UPGRADE_SUCCESSFUL),
+ fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN),
+ ]
+ )
+
+ def test_success_full(self):
+ defaults_xml = f"""
+ <{self.tag}>
+ <meta_attributes id="my-id" score="10">
+ <rule id="my-id-rule" boolean-op="and" score="INFINITY">
+ <rsc_expression id="my-id-rule-rsc-ocf-pacemaker-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ </rule>
+ <nvpair id="my-id-name1" name="name1" value="value1" />
+ <nvpair id="my-id-2name" name="2na#me" value="value2" />
+ </meta_attributes>
+ </{self.tag}>
+ """
+ self.config.runner.cib.load(
+ filename="cib-empty-3.4.xml", instead="runner.cib.load"
+ )
+ self.config.env.push_cib(optional_in_conf=defaults_xml)
+
+ self.command(
+ self.env_assist.get_env(),
+ {"name1": "value1", "2na#me": "value2"},
+ {"id": "my-id", "score": "10"},
+ nvset_rule="resource ocf:pacemaker:Dummy",
+ )
+
+ self.env_assist.assert_reports(
+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)]
+ )
+
+ def test_validation(self):
+ self.config.runner.cib.load(
+ filename="cib-empty-3.4.xml", instead="runner.cib.load"
+ )
+ self.env_assist.assert_raise_library_error(
+ lambda: self.command(
+ self.env_assist.get_env(),
+ {},
+ {"unknown-option": "value"},
+ "bad rule",
+ )
+ )
+ self.env_assist.assert_reports(
+ [
+ fixture.error(
+ reports.codes.INVALID_OPTIONS,
+ force_code=reports.codes.FORCE_OPTIONS,
+ option_names=["unknown-option"],
+ allowed=["id", "score"],
+ option_type=None,
+ allowed_patterns=[],
+ ),
+ fixture.error(
+ reports.codes.RULE_EXPRESSION_PARSE_ERROR,
+ rule_string="bad rule",
+ reason='Expected "resource"',
+ rule_line="bad rule",
+ line_number=1,
+ column_number=1,
+ position=0,
+ ),
+ ]
+ )
+
+ def test_validation_forced(self):
+ defaults_xml = f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes"
+ unknown-option="value"
+ />
+ </{self.tag}>
+ """
+ self.config.env.push_cib(optional_in_conf=defaults_xml)
+
+ self.command(
+ self.env_assist.get_env(),
+ {},
+ {"unknown-option": "value"},
+ force_flags={reports.codes.FORCE_OPTIONS},
+ )
+
+ self.env_assist.assert_reports(
+ [
+ fixture.warn(
+ reports.codes.INVALID_OPTIONS,
+ option_names=["unknown-option"],
+ allowed=["id", "score"],
+ option_type=None,
+ allowed_patterns=[],
+ ),
+ fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN),
+ ]
+ )
+
+
+class ResourceDefaultsCreate(DefaultsCreateMixin, TestCase):
+ command = staticmethod(cib_options.resource_defaults_create)
+ tag = "rsc_defaults"
+
+ def test_rule_op_expression_not_allowed(self):
+ self.config.runner.cib.load(
+ filename="cib-empty-3.4.xml", instead="runner.cib.load"
+ )
+ self.env_assist.assert_raise_library_error(
+ lambda: self.command(
+ self.env_assist.get_env(), {}, {}, "op monitor"
+ )
+ )
+ self.env_assist.assert_reports(
+ [
+ fixture.error(
+ reports.codes.RULE_EXPRESSION_NOT_ALLOWED,
+ expression_type=CibRuleExpressionType.OP_EXPRESSION,
+ ),
+ ]
+ )
+
+
+class OperationDefaultsCreate(DefaultsCreateMixin, TestCase):
+ command = staticmethod(cib_options.operation_defaults_create)
+ tag = "op_defaults"
+
+
+class DefaultsConfigMixin:
+ command = lambda *args, **kwargs: None
+ tag = ""
+
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.env_assist, self.config = get_env_tools(self)
+
+ def test_empty(self):
+ defaults_xml = f"""<{self.tag} />"""
+ self.config.runner.cib.load(
+ filename="cib-empty-3.4.xml", optional_in_conf=defaults_xml
+ )
+ self.assertEqual([], self.command(self.env_assist.get_env()))
+
+ def test_full(self):
+ defaults_xml = f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes">
+ <rule id="{self.tag}-meta_attributes-rule"
+ boolean-op="and" score="INFINITY"
+ >
+ <rsc_expression
+ id="{self.tag}-meta_attributes-rule-rsc-Dummy"
+ class="ocf" provider="pacemaker" type="Dummy"
+ />
+ </rule>
+ <nvpair id="my-id-pair1" name="name1" value="value1" />
+ <nvpair id="my-id-pair2" name="name2" value="value2" />
+ </meta_attributes>
+ <instance_attributes id="instance">
+ <nvpair id="instance-pair" name="inst" value="ance" />
+ </instance_attributes>
+ <meta_attributes id="meta-plain" score="123">
+ <nvpair id="my-id-pair3" name="name1" value="value1" />
+ </meta_attributes>
+ </{self.tag}>
+ """
+ self.config.runner.cib.load(
+ filename="cib-empty-3.4.xml", optional_in_conf=defaults_xml
+ )
+ self.assertEqual(
+ [
+ CibNvsetDto(
+ f"{self.tag}-meta_attributes",
+ CibNvsetType.META,
+ {},
+ CibRuleExpressionDto(
+ f"{self.tag}-meta_attributes-rule",
+ CibRuleExpressionType.RULE,
+ False,
+ {"boolean-op": "and", "score": "INFINITY"},
+ None,
+ None,
+ [
+ CibRuleExpressionDto(
+ f"{self.tag}-meta_attributes-rule-rsc-Dummy",
+ CibRuleExpressionType.RSC_EXPRESSION,
+ False,
+ {
+ "class": "ocf",
+ "provider": "pacemaker",
+ "type": "Dummy",
+ },
+ None,
+ None,
+ [],
+ "resource ocf:pacemaker:Dummy",
+ ),
+ ],
+ "resource ocf:pacemaker:Dummy",
+ ),
+ [
+ CibNvpairDto("my-id-pair1", "name1", "value1"),
+ CibNvpairDto("my-id-pair2", "name2", "value2"),
+ ],
+ ),
+ CibNvsetDto(
+ "instance",
+ CibNvsetType.INSTANCE,
+ {},
+ None,
+ [CibNvpairDto("instance-pair", "inst", "ance")],
+ ),
+ CibNvsetDto(
+ "meta-plain",
+ CibNvsetType.META,
+ {"score": "123"},
+ None,
+ [CibNvpairDto("my-id-pair3", "name1", "value1")],
+ ),
+ ],
+ self.command(self.env_assist.get_env()),
+ )
+
+
+class ResourceDefaultsConfig(DefaultsConfigMixin, TestCase):
+ command = staticmethod(cib_options.resource_defaults_config)
+ tag = "rsc_defaults"
+
+
+class OperationDefaultsConfig(DefaultsConfigMixin, TestCase):
+ command = staticmethod(cib_options.operation_defaults_config)
+ tag = "op_defaults"
+
+
+class DefaultsRemoveMixin:
+ command = lambda *args, **kwargs: None
+ tag = ""
+
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.env_assist, self.config = get_env_tools(self)
+
+ def test_nothing_to_delete(self):
+ self.command(self.env_assist.get_env(), [])
+
+ def test_defaults_section_missing(self):
+ self.config.runner.cib.load(filename="cib-empty-1.2.xml")
+ self.env_assist.assert_raise_library_error(
+ lambda: self.command(self.env_assist.get_env(), ["set1"])
+ )
+ self.env_assist.assert_reports(
+ [
+ fixture.report_not_found(
+ "set1",
+ context_type=self.tag,
+ expected_types=["options set"],
+ ),
+ ]
+ )
+
+ def test_success(self):
+ self.config.runner.cib.load(
+ filename="cib-empty-1.2.xml",
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="set1" />
+ <instance_attributes id="set2" />
+ <not_an_nvset id="set3" />
+ <meta_attributes id="set4" />
+ <instance_attributes id="set5" />
+ </{self.tag}>
+ """,
+ )
+ self.config.env.push_cib(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="set1" />
+ <not_an_nvset id="set3" />
+ <meta_attributes id="set4" />
+ </{self.tag}>
+ """
+ )
+ self.command(self.env_assist.get_env(), ["set2", "set5"])
+
+ def test_delete_all_keep_the_section(self):
+ self.config.runner.cib.load(
+ filename="cib-empty-1.2.xml",
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="set1" />
+ </{self.tag}>
+ """,
+ )
+ self.config.env.push_cib(optional_in_conf=f"<{self.tag} />")
+ self.command(self.env_assist.get_env(), ["set1"])
+
+ def test_nvset_not_found(self):
+ self.config.runner.cib.load(
+ filename="cib-empty-1.2.xml",
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="set1" />
+ <instance_attributes id="set2" />
+ <not_an_nvset id="set3" />
+ <meta_attributes id="set4" />
+ <instance_attributes id="set5" />
+ </{self.tag}>
+ """,
+ )
+ self.env_assist.assert_raise_library_error(
+ lambda: self.command(
+ self.env_assist.get_env(), ["set2", "set3", "setX"]
+ )
+ )
+ self.env_assist.assert_reports(
+ [
+ fixture.report_unexpected_element(
+ "set3", "not_an_nvset", ["options set"]
+ ),
+ fixture.report_not_found(
+ "setX",
+ context_type=self.tag,
+ expected_types=["options set"],
+ ),
+ ]
+ )
+
+
+class ResourceDefaultsRemove(DefaultsRemoveMixin, TestCase):
+ command = staticmethod(cib_options.resource_defaults_remove)
+ tag = "rsc_defaults"
+
+
+class OperationDefaultsRemove(DefaultsRemoveMixin, TestCase):
+ command = staticmethod(cib_options.operation_defaults_remove)
+ tag = "op_defaults"
+
+
+class DefaultsUpdateLegacyMixin:
+ # This class tests legacy use cases of not providing an nvset ID
+ command = lambda *args, **kwargs: None
+ tag = ""
+ command_for_report = None
+
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.env_assist, self.config = get_env_tools(self)
+ self.reports = [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)]
+
+ def tearDown(self):
+ # pylint: disable=invalid-name
+ self.env_assist.assert_reports(self.reports)
+
+ def fixture_initial_defaults(self):
+ return f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-options">
+ <nvpair id="{self.tag}-options-a" name="a" value="b"/>
+ <nvpair id="{self.tag}-options-b" name="b" value="c"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+
+ def test_change(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.config.env.push_cib(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-options">
+ <nvpair id="{self.tag}-options-a" name="a" value="B"/>
+ <nvpair id="{self.tag}-options-b" name="b" value="C"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ )
+ self.command(self.env_assist.get_env(), None, {"a": "B", "b": "C"})
+
+ def test_add(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.config.env.push_cib(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-options">
+ <nvpair id="{self.tag}-options-a" name="a" value="b"/>
+ <nvpair id="{self.tag}-options-b" name="b" value="c"/>
+ <nvpair id="{self.tag}-options-c" name="c" value="d"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ )
+ self.command(self.env_assist.get_env(), None, {"c": "d"})
+
+ def test_remove(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.config.env.push_cib(
+ remove=(
+ f"./configuration/{self.tag}/meta_attributes/nvpair[@name='a']"
+ )
+ )
+ self.command(self.env_assist.get_env(), None, {"a": ""})
+
+ def test_add_section_if_missing(self):
+ self.config.runner.cib.load()
+ self.config.env.push_cib(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes">
+ <nvpair id="{self.tag}-meta_attributes-a" name="a" value="A"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ )
+ self.command(self.env_assist.get_env(), None, {"a": "A"})
+
+ def test_add_meta_if_missing(self):
+ self.config.runner.cib.load(optional_in_conf=f"<{self.tag} />")
+ self.config.env.push_cib(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-meta_attributes">
+ <nvpair id="{self.tag}-meta_attributes-a" name="a" value="A"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ )
+ self.command(self.env_assist.get_env(), None, {"a": "A"})
+
+ def test_dont_add_section_if_only_removing(self):
+ self.config.runner.cib.load()
+ self.command(self.env_assist.get_env(), None, {"a": "", "b": ""})
+
+ def test_dont_add_meta_if_only_removing(self):
+ self.config.runner.cib.load(optional_in_conf=f"<{self.tag} />")
+ self.command(self.env_assist.get_env(), None, {"a": "", "b": ""})
+
+ def test_keep_section_when_empty(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.config.env.push_cib(remove=f"./configuration/{self.tag}//nvpair")
+ self.command(self.env_assist.get_env(), None, {"a": "", "b": ""})
+
+ def test_ambiguous(self):
+ self.config.runner.cib.load(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-options">
+ <nvpair id="{self.tag}-options-a" name="a" value="b"/>
+ <nvpair id="{self.tag}-options-b" name="b" value="c"/>
+ </meta_attributes>
+ <meta_attributes id="{self.tag}-options-1">
+ <nvpair id="{self.tag}-options-c" name="c" value="d"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ )
+ self.env_assist.assert_raise_library_error(
+ lambda: self.command(self.env_assist.get_env(), None, {"x": "y"})
+ )
+ self.reports = [
+ fixture.error(
+ reports.codes.CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID,
+ pcs_command=self.command_for_report,
+ )
+ ]
+
+
+class DefaultsUpdateMixin:
+ command = lambda *args, **kwargs: None
+ tag = ""
+
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.env_assist, self.config = get_env_tools(self)
+
+ def fixture_initial_defaults(self):
+ return f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-options">
+ <nvpair id="{self.tag}-options-a" name="a" value="b"/>
+ <nvpair id="{self.tag}-options-b" name="b" value="c"/>
+ <nvpair id="{self.tag}-options-c" name="c" value="d"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+
+ def test_success(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.config.env.push_cib(
+ optional_in_conf=f"""
+ <{self.tag}>
+ <meta_attributes id="{self.tag}-options">
+ <nvpair id="{self.tag}-options-a" name="a" value="B"/>
+ <nvpair id="{self.tag}-options-b" name="b" value="c"/>
+ <nvpair id="{self.tag}-options-d" name="d" value="e"/>
+ </meta_attributes>
+ </{self.tag}>
+ """
+ )
+ self.command(
+ self.env_assist.get_env(),
+ f"{self.tag}-options",
+ {"a": "B", "c": "", "d": "e"},
+ )
+ self.env_assist.assert_reports(
+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)]
+ )
+
+ def test_nvset_doesnt_exist(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.env_assist.assert_raise_library_error(
+ lambda: self.command(
+ self.env_assist.get_env(), "wrong-nvset-id", {},
+ )
+ )
+ self.env_assist.assert_reports(
+ [
+ fixture.report_not_found(
+ "wrong-nvset-id",
+ context_type=self.tag,
+ expected_types=["options set"],
+ ),
+ ]
+ )
+
+ def test_keep_elements_when_empty(self):
+ self.config.runner.cib.load(
+ optional_in_conf=self.fixture_initial_defaults()
+ )
+ self.config.env.push_cib(remove=f"./configuration/{self.tag}//nvpair")
+ self.command(
+ self.env_assist.get_env(),
+ f"{self.tag}-options",
+ {"a": "", "b": "", "c": ""},
+ )
+ self.env_assist.assert_reports(
+ [fixture.warn(reports.codes.DEFAULTS_CAN_BE_OVERRIDEN)]
+ )
+
+
+class ResourceDefaultsUpdateLegacy(DefaultsUpdateLegacyMixin, TestCase):
+ command = staticmethod(cib_options.resource_defaults_update)
+ tag = "rsc_defaults"
+ command_for_report = reports.const.PCS_COMMAND_RESOURCE_DEFAULTS_UPDATE
+
+
+class OperationDefaultsUpdateLegacy(DefaultsUpdateLegacyMixin, TestCase):
+ command = staticmethod(cib_options.operation_defaults_update)
+ tag = "op_defaults"
+ command_for_report = reports.const.PCS_COMMAND_OPERATION_DEFAULTS_UPDATE
+
+
+class ResourceDefaultsUpdate(DefaultsUpdateMixin, TestCase):
+ command = staticmethod(cib_options.resource_defaults_update)
+ tag = "rsc_defaults"
+
+
+class OperationDefaultsUpdate(DefaultsUpdateMixin, TestCase):
+ command = staticmethod(cib_options.operation_defaults_update)
+ tag = "op_defaults"
diff --git a/pcs_test/tier0/lib/test_validate.py b/pcs_test/tier0/lib/test_validate.py
index 002fd8ed..8c0e0261 100644
--- a/pcs_test/tier0/lib/test_validate.py
+++ b/pcs_test/tier0/lib/test_validate.py
@@ -1238,6 +1238,33 @@ class ValuePositiveInteger(TestCase):
)
+class ValueScore(TestCase):
+ def test_valid_score(self):
+ for score in [
+ "1",
+ "-1",
+ "+1",
+ "123",
+ "-123",
+ "+123",
+ "INFINITY",
+ "-INFINITY",
+ "+INFINITY",
+ ]:
+ with self.subTest(score=score):
+ assert_report_item_list_equal(
+ validate.ValueScore("a").validate({"a": score}), [],
+ )
+
+ def test_not_valid_score(self):
+ for score in ["something", "++1", "--1", "++INFINITY"]:
+ with self.subTest(score=score):
+ assert_report_item_list_equal(
+ validate.ValueScore("a").validate({"a": score}),
+ [fixture.error(report_codes.INVALID_SCORE, score=score,),],
+ )
+
+
class ValueTimeInterval(TestCase):
def test_no_reports_for_valid_time_interval(self):
for interval in ["0", "1s", "2sec", "3m", "4min", "5h", "6hr"]:
diff --git a/pcs_test/tier1/legacy/test_resource.py b/pcs_test/tier1/legacy/test_resource.py
index 5770d81a..7ffcc83b 100644
--- a/pcs_test/tier1/legacy/test_resource.py
+++ b/pcs_test/tier1/legacy/test_resource.py
@@ -1421,9 +1421,9 @@ monitor interval=20 (A-monitor-interval-20)
No alerts defined
Resources Defaults:
- No defaults set
+ No defaults set
Operations Defaults:
- No defaults set
+ No defaults set
Cluster Properties:
@@ -1657,9 +1657,9 @@ monitor interval=20 (A-monitor-interval-20)
No alerts defined
Resources Defaults:
- No defaults set
+ No defaults set
Operations Defaults:
- No defaults set
+ No defaults set
Cluster Properties:
diff --git a/pcs_test/tier1/legacy/test_stonith.py b/pcs_test/tier1/legacy/test_stonith.py
index 3fc2c4d5..c51a02b5 100644
--- a/pcs_test/tier1/legacy/test_stonith.py
+++ b/pcs_test/tier1/legacy/test_stonith.py
@@ -293,9 +293,9 @@ class StonithTest(TestCase, AssertPcsMixin):
No alerts defined
Resources Defaults:
- No defaults set
+ No defaults set
Operations Defaults:
- No defaults set
+ No defaults set
Cluster Properties:
@@ -1305,9 +1305,9 @@ class LevelConfig(LevelTestsBase):
No alerts defined
Resources Defaults:
- No defaults set
+ No defaults set
Operations Defaults:
- No defaults set
+ No defaults set
Cluster Properties:
diff --git a/pcs_test/tier1/test_cib_options.py b/pcs_test/tier1/test_cib_options.py
new file mode 100644
index 00000000..ba8f3515
--- /dev/null
+++ b/pcs_test/tier1/test_cib_options.py
@@ -0,0 +1,571 @@
+from textwrap import dedent
+from unittest import TestCase
+
+from lxml import etree
+
+from pcs_test.tools.assertions import AssertPcsMixin
+from pcs_test.tools.cib import get_assert_pcs_effect_mixin
+from pcs_test.tools.misc import (
+ get_test_resource as rc,
+ get_tmp_file,
+ skip_unless_pacemaker_supports_rsc_and_op_rules,
+ write_data_to_tmpfile,
+ write_file_to_tmpfile,
+)
+from pcs_test.tools.pcs_runner import PcsRunner
+from pcs_test.tools.xml import XmlManipulation
+
+
+empty_cib = rc("cib-empty-2.0.xml")
+empty_cib_rules = rc("cib-empty-3.4.xml")
+
+
+class TestDefaultsMixin:
+ def setUp(self):
+ # pylint: disable=invalid-name
+ self.temp_cib = get_tmp_file("tier1_cib_options")
+ self.pcs_runner = PcsRunner(self.temp_cib.name)
+
+ def tearDown(self):
+ # pylint: disable=invalid-name
+ self.temp_cib.close()
+
+
+class DefaultsConfigMixin(TestDefaultsMixin, AssertPcsMixin):
+ cli_command = ""
+ prefix = ""
+
+ def test_success(self):
+ xml_rsc = """
+ <rsc_defaults>
+ <meta_attributes id="rsc-set1" score="10">
+ <nvpair id="rsc-set1-nv1" name="name1" value="rsc1"/>
+ <nvpair id="rsc-set1-nv2" name="name2" value="rsc2"/>
+ </meta_attributes>
+ <meta_attributes id="rsc-setA">
+ <nvpair id="rsc-setA-nv1" name="name1" value="rscA"/>
+ <nvpair id="rsc-setA-nv2" name="name2" value="rscB"/>
+ </meta_attributes>
+ </rsc_defaults>
+ """
+ xml_op = """
+ <op_defaults>
+ <meta_attributes id="op-set1" score="10">
+ <nvpair id="op-set1-nv1" name="name1" value="op1"/>
+ <nvpair id="op-set1-nv2" name="name2" value="op2"/>
+ </meta_attributes>
+ <meta_attributes id="op-setA">
+ <nvpair id="op-setA-nv1" name="name1" value="opA"/>
+ <nvpair id="op-setA-nv2" name="name2" value="opB"/>
+ </meta_attributes>
+ </op_defaults>
+ """
+ xml_manip = XmlManipulation.from_file(empty_cib)
+ xml_manip.append_to_first_tag_name("configuration", xml_rsc, xml_op)
+ write_data_to_tmpfile(str(xml_manip), self.temp_cib)
+
+ self.assert_pcs_success(
+ self.cli_command,
+ stdout_full=dedent(
+ f"""\
+ Meta Attrs: {self.prefix}-set1 score=10
+ name1={self.prefix}1
+ name2={self.prefix}2
+ Meta Attrs: {self.prefix}-setA
+ name1={self.prefix}A
+ name2={self.prefix}B
+ """
+ ),
+ )
+
+
+class RscDefaultsConfig(
+ DefaultsConfigMixin, TestCase,
+):
+ cli_command = "resource defaults"
+ prefix = "rsc"
+
+ @skip_unless_pacemaker_supports_rsc_and_op_rules()
+ def test_success_rules(self):
+ xml = """
+ <rsc_defaults>
+ <meta_attributes id="X">
+ <rule id="X-rule" boolean-op="and" score="INFINITY">
+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/>
+ </rule>
+ <nvpair id="X-nam1" name="nam1" value="val1"/>
+ </meta_attributes>
+ </rsc_defaults>
+ """
+ xml_manip = XmlManipulation.from_file(empty_cib_rules)
+ xml_manip.append_to_first_tag_name("configuration", xml)
+ write_data_to_tmpfile(str(xml_manip), self.temp_cib)
+
+ self.assert_pcs_success(
+ self.cli_command,
+ stdout_full=dedent(
+ """\
+ Meta Attrs: X
+ nam1=val1
+ Rule: boolean-op=and score=INFINITY
+ Expression: resource ::Dummy
+ """
+ ),
+ )
+
+
+class OpDefaultsConfig(
+ DefaultsConfigMixin, TestCase,
+):
+ cli_command = "resource op defaults"
+ prefix = "op"
+
+ @skip_unless_pacemaker_supports_rsc_and_op_rules()
+ def test_success_rules(self):
+ xml = """
+ <op_defaults>
+ <meta_attributes id="X">
+ <rule id="X-rule" boolean-op="and" score="INFINITY">
+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/>
+ <op_expression id="X-rule-op-monitor" name="monitor"/>
+ </rule>
+ <nvpair id="X-nam1" name="nam1" value="val1"/>
+ </meta_attributes>
+ </op_defaults>
+ """
+ xml_manip = XmlManipulation.from_file(empty_cib_rules)
+ xml_manip.append_to_first_tag_name("configuration", xml)
+ write_data_to_tmpfile(str(xml_manip), self.temp_cib)
+
+ self.assert_pcs_success(
+ self.cli_command,
+ stdout_full=dedent(
+ """\
+ Meta Attrs: X
+ nam1=val1
+ Rule: boolean-op=and score=INFINITY
+ Expression: resource ::Dummy
+ Expression: op monitor
+ """
+ ),
+ )
+
+
+class DefaultsSetCreateMixin(TestDefaultsMixin):
+ cli_command = ""
+ cib_tag = ""
+
+ def setUp(self):
+ super().setUp()
+ write_file_to_tmpfile(empty_cib, self.temp_cib)
+
+ def test_no_args(self):
+ self.assert_effect(
+ f"{self.cli_command} set create",
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="{self.cib_tag}-meta_attributes"/>
+ </{self.cib_tag}>
+ """
+ ),
+ output=(
+ "Warning: Defaults do not apply to resources which override "
+ "them with their own defined values\n"
+ ),
+ )
+
+ def test_success(self):
+ self.assert_effect(
+ (
+ f"{self.cli_command} set create id=mine score=10 "
+ "meta nam1=val1 nam2=val2 --force"
+ ),
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="mine" score="10">
+ <nvpair id="mine-nam1" name="nam1" value="val1"/>
+ <nvpair id="mine-nam2" name="nam2" value="val2"/>
+ </meta_attributes>
+ </{self.cib_tag}>
+ """
+ ),
+ output=(
+ "Warning: Defaults do not apply to resources which override "
+ "them with their own defined values\n"
+ ),
+ )
+
+
+class RscDefaultsSetCreate(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//rsc_defaults")[0]
+ )
+ ),
+ DefaultsSetCreateMixin,
+ TestCase,
+):
+ cli_command = "resource defaults"
+ cib_tag = "rsc_defaults"
+
+ @skip_unless_pacemaker_supports_rsc_and_op_rules()
+ def test_success_rules(self):
+ self.assert_effect(
+ (
+ f"{self.cli_command} set create id=X meta nam1=val1 "
+ "rule resource ::Dummy"
+ ),
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="X">
+ <rule id="X-rule" boolean-op="and" score="INFINITY">
+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/>
+ </rule>
+ <nvpair id="X-nam1" name="nam1" value="val1"/>
+ </meta_attributes>
+ </{self.cib_tag}>
+ """,
+ output=(
+ "CIB has been upgraded to the latest schema version.\n"
+ "Warning: Defaults do not apply to resources which override "
+ "them with their own defined values\n"
+ ),
+ )
+
+
+class OpDefaultsSetCreate(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//op_defaults")[0]
+ )
+ ),
+ DefaultsSetCreateMixin,
+ TestCase,
+):
+ cli_command = "resource op defaults"
+ cib_tag = "op_defaults"
+
+ @skip_unless_pacemaker_supports_rsc_and_op_rules()
+ def test_success_rules(self):
+ self.assert_effect(
+ (
+ f"{self.cli_command} set create id=X meta nam1=val1 "
+ "rule resource ::Dummy and op monitor"
+ ),
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="X">
+ <rule id="X-rule" boolean-op="and" score="INFINITY">
+ <rsc_expression id="X-rule-rsc-Dummy" type="Dummy"/>
+ <op_expression id="X-rule-op-monitor" name="monitor"/>
+ </rule>
+ <nvpair id="X-nam1" name="nam1" value="val1"/>
+ </meta_attributes>
+ </{self.cib_tag}>
+ """,
+ output=(
+ "CIB has been upgraded to the latest schema version.\n"
+ "Warning: Defaults do not apply to resources which override "
+ "them with their own defined values\n"
+ ),
+ )
+
+
+class DefaultsSetDeleteMixin(TestDefaultsMixin, AssertPcsMixin):
+ cli_command = ""
+ prefix = ""
+ cib_tag = ""
+
+ def setUp(self):
+ super().setUp()
+ xml_rsc = """
+ <rsc_defaults>
+ <meta_attributes id="rsc-set1" />
+ <meta_attributes id="rsc-set2" />
+ <meta_attributes id="rsc-set3" />
+ <meta_attributes id="rsc-set4" />
+ </rsc_defaults>
+ """
+ xml_op = """
+ <op_defaults>
+ <meta_attributes id="op-set1" />
+ <meta_attributes id="op-set2" />
+ <meta_attributes id="op-set3" />
+ <meta_attributes id="op-set4" />
+ </op_defaults>
+ """
+ xml_manip = XmlManipulation.from_file(empty_cib)
+ xml_manip.append_to_first_tag_name("configuration", xml_rsc, xml_op)
+ write_data_to_tmpfile(str(xml_manip), self.temp_cib)
+
+ def test_success(self):
+ self.assert_effect(
+ [
+ f"{self.cli_command} set delete {self.prefix}-set1 "
+ f"{self.prefix}-set3",
+ f"{self.cli_command} set remove {self.prefix}-set1 "
+ f"{self.prefix}-set3",
+ ],
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="{self.prefix}-set2" />
+ <meta_attributes id="{self.prefix}-set4" />
+ </{self.cib_tag}>
+ """
+ ),
+ )
+
+
+class RscDefaultsSetDelete(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//rsc_defaults")[0]
+ )
+ ),
+ DefaultsSetDeleteMixin,
+ TestCase,
+):
+ cli_command = "resource defaults"
+ prefix = "rsc"
+ cib_tag = "rsc_defaults"
+
+
+class OpDefaultsSetDelete(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//op_defaults")[0]
+ )
+ ),
+ DefaultsSetDeleteMixin,
+ TestCase,
+):
+ cli_command = "resource op defaults"
+ prefix = "op"
+ cib_tag = "op_defaults"
+
+
+class DefaultsSetUpdateMixin(TestDefaultsMixin, AssertPcsMixin):
+ cli_command = ""
+ prefix = ""
+ cib_tag = ""
+
+ def test_success(self):
+ xml = f"""
+ <{self.cib_tag}>
+ <meta_attributes id="my-set">
+ <nvpair id="my-set-name1" name="name1" value="value1" />
+ <nvpair id="my-set-name2" name="name2" value="value2" />
+ <nvpair id="my-set-name3" name="name3" value="value3" />
+ </meta_attributes>
+ </{self.cib_tag}>
+ """
+ xml_manip = XmlManipulation.from_file(empty_cib)
+ xml_manip.append_to_first_tag_name("configuration", xml)
+ write_data_to_tmpfile(str(xml_manip), self.temp_cib)
+ warnings = (
+ "Warning: Defaults do not apply to resources which override "
+ "them with their own defined values\n"
+ )
+
+ self.assert_effect(
+ f"{self.cli_command} set update my-set meta name2=value2A name3=",
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="my-set">
+ <nvpair id="my-set-name1" name="name1" value="value1" />
+ <nvpair id="my-set-name2" name="name2" value="value2A" />
+ </meta_attributes>
+ </{self.cib_tag}>
+ """
+ ),
+ output=warnings,
+ )
+
+ self.assert_effect(
+ f"{self.cli_command} set update my-set meta name1= name2=",
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="my-set" />
+ </{self.cib_tag}>
+ """
+ ),
+ output=warnings,
+ )
+
+
+class RscDefaultsSetUpdate(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//rsc_defaults")[0]
+ )
+ ),
+ DefaultsSetUpdateMixin,
+ TestCase,
+):
+ cli_command = "resource defaults"
+ prefix = "rsc"
+ cib_tag = "rsc_defaults"
+
+
+class OpDefaultsSetUpdate(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//op_defaults")[0]
+ )
+ ),
+ DefaultsSetUpdateMixin,
+ TestCase,
+):
+ cli_command = "resource op defaults"
+ prefix = "op"
+ cib_tag = "op_defaults"
+
+
+class DefaultsSetUsageMixin(TestDefaultsMixin, AssertPcsMixin):
+ cli_command = ""
+
+ def test_no_args(self):
+ self.assert_pcs_fail(
+ f"{self.cli_command} set",
+ stdout_start=f"\nUsage: pcs {self.cli_command} set...\n",
+ )
+
+ def test_bad_command(self):
+ self.assert_pcs_fail(
+ f"{self.cli_command} set bad-command",
+ stdout_start=f"\nUsage: pcs {self.cli_command} set ...\n",
+ )
+
+
+class RscDefaultsSetUsage(
+ DefaultsSetUsageMixin, TestCase,
+):
+ cli_command = "resource defaults"
+
+
+class OpDefaultsSetUsage(
+ DefaultsSetUsageMixin, TestCase,
+):
+ cli_command = "resource op defaults"
+
+
+class DefaultsUpdateMixin(TestDefaultsMixin, AssertPcsMixin):
+ cli_command = ""
+ prefix = ""
+ cib_tag = ""
+
+ def assert_success_legacy(self, update_keyword):
+ write_file_to_tmpfile(empty_cib, self.temp_cib)
+ warning_lines = []
+ if not update_keyword:
+ warning_lines.append(
+ "Warning: This command is deprecated and will be removed. "
+ f"Please use 'pcs {self.cli_command} update' instead.\n"
+ )
+ warning_lines.append(
+ "Warning: Defaults do not apply to resources which override "
+ "them with their own defined values\n"
+ )
+ warnings = "".join(warning_lines)
+
+ update = "update" if update_keyword else ""
+
+ self.assert_effect(
+ f"{self.cli_command} {update} name1=value1 name2=value2 name3=value3",
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="{self.cib_tag}-meta_attributes">
+ <nvpair id="{self.cib_tag}-meta_attributes-name1"
+ name="name1" value="value1"
+ />
+ <nvpair id="{self.cib_tag}-meta_attributes-name2"
+ name="name2" value="value2"
+ />
+ <nvpair id="{self.cib_tag}-meta_attributes-name3"
+ name="name3" value="value3"
+ />
+ </meta_attributes>
+ </{self.cib_tag}>
+ """
+ ),
+ output=warnings,
+ )
+
+ self.assert_effect(
+ f"{self.cli_command} {update} name2=value2A name3=",
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="{self.cib_tag}-meta_attributes">
+ <nvpair id="{self.cib_tag}-meta_attributes-name1"
+ name="name1" value="value1"
+ />
+ <nvpair id="{self.cib_tag}-meta_attributes-name2"
+ name="name2" value="value2A"
+ />
+ </meta_attributes>
+ </{self.cib_tag}>
+ """
+ ),
+ output=warnings,
+ )
+
+ self.assert_effect(
+ f"{self.cli_command} {update} name1= name2=",
+ dedent(
+ f"""\
+ <{self.cib_tag}>
+ <meta_attributes id="{self.cib_tag}-meta_attributes" />
+ </{self.cib_tag}>
+ """
+ ),
+ output=warnings,
+ )
+
+ def test_deprecated(self):
+ self.assert_success_legacy(False)
+
+ def test_legacy(self):
+ self.assert_success_legacy(True)
+
+
+class RscDefaultsUpdate(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//rsc_defaults")[0]
+ )
+ ),
+ DefaultsUpdateMixin,
+ TestCase,
+):
+ cli_command = "resource defaults"
+ prefix = "rsc"
+ cib_tag = "rsc_defaults"
+
+
+class OpDefaultsUpdate(
+ get_assert_pcs_effect_mixin(
+ lambda cib: etree.tostring(
+ # pylint:disable=undefined-variable
+ etree.parse(cib).findall(".//op_defaults")[0]
+ )
+ ),
+ DefaultsUpdateMixin,
+ TestCase,
+):
+ cli_command = "resource op defaults"
+ prefix = "op"
+ cib_tag = "op_defaults"
diff --git a/pcs_test/tier1/test_tag.py b/pcs_test/tier1/test_tag.py
index d28d3ae5..8057476a 100644
--- a/pcs_test/tier1/test_tag.py
+++ b/pcs_test/tier1/test_tag.py
@@ -246,9 +246,9 @@ class PcsConfigTagsTest(TestTagMixin, TestCase):
No alerts defined
Resources Defaults:
- No defaults set
+ No defaults set
Operations Defaults:
- No defaults set
+ No defaults set
Cluster Properties:
{tags}
diff --git a/pcs_test/tools/fixture.py b/pcs_test/tools/fixture.py
index a460acc7..6480617e 100644
--- a/pcs_test/tools/fixture.py
+++ b/pcs_test/tools/fixture.py
@@ -245,14 +245,14 @@ def report_resource_running(resource, roles, severity=severities.INFO):
)
-def report_unexpected_element(element_id, elemet_type, expected_types):
+def report_unexpected_element(element_id, element_type, expected_types):
return (
severities.ERROR,
report_codes.ID_BELONGS_TO_UNEXPECTED_TYPE,
{
"id": element_id,
"expected_types": expected_types,
- "current_type": elemet_type,
+ "current_type": element_type,
},
None,
)
diff --git a/pcs_test/tools/misc.py b/pcs_test/tools/misc.py
index f481a267..33d78002 100644
--- a/pcs_test/tools/misc.py
+++ b/pcs_test/tools/misc.py
@@ -5,6 +5,8 @@ import re
import tempfile
from unittest import mock, skipUnless
+from lxml import etree
+
from pcs_test.tools.custom_mock import MockLibraryReportProcessor
from pcs import settings
@@ -128,12 +130,12 @@ def compare_version(a, b):
def is_minimum_pacemaker_version(major, minor, rev):
return is_version_sufficient(
- get_current_pacemaker_version(), (major, minor, rev)
+ _get_current_pacemaker_version(), (major, minor, rev)
)
@lru_cache()
-def get_current_pacemaker_version():
+def _get_current_pacemaker_version():
output, dummy_stderr, dummy_retval = runner.run(
[os.path.join(settings.pacemaker_binaries, "crm_mon"), "--version",]
)
@@ -146,6 +148,26 @@ def get_current_pacemaker_version():
return major, minor, rev
+@lru_cache()
+def _get_current_cib_schema_version():
+ regexp = re.compile(r"pacemaker-((\d+)\.(\d+))")
+ all_versions = set()
+ xml = etree.parse("/usr/share/pacemaker/versions.rng").getroot()
+ for value_el in xml.xpath(
+ ".//x:attribute[@name='validate-with']//x:value",
+ namespaces={"x": "http://relaxng.org/ns/structure/1.0"},
+ ):
+ match = re.match(regexp, value_el.text)
+ if match:
+ all_versions.add((int(match.group(2)), int(match.group(3))))
+ return sorted(all_versions)[-1]
+
+
+def _is_minimum_cib_schema_version(cmajor, cminor, crev):
+ major, minor = _get_current_cib_schema_version()
+ return compare_version((major, minor, 0), (cmajor, cminor, crev)) > -1
+
+
def is_version_sufficient(current_version, minimal_version):
return compare_version(current_version, minimal_version) > -1
@@ -174,7 +196,7 @@ def _get_current_pacemaker_features():
def skip_unless_pacemaker_version(version_tuple, feature):
- current_version = get_current_pacemaker_version()
+ current_version = _get_current_pacemaker_version()
return skipUnless(
is_version_sufficient(current_version, version_tuple),
(
@@ -188,12 +210,6 @@ def skip_unless_pacemaker_version(version_tuple, feature):
)
-def skip_unless_crm_rule():
- return skip_unless_pacemaker_version(
- (2, 0, 2), "listing of constraints that might be expired"
- )
-
-
def skip_unless_pacemaker_features(version_tuple, feature):
return skipUnless(
is_minimum_pacemaker_features(*version_tuple),
@@ -204,12 +220,39 @@ def skip_unless_pacemaker_features(version_tuple, feature):
)
+def skip_unless_cib_schema_version(version_tuple, feature):
+ current_version = _get_current_cib_schema_version()
+ return skipUnless(
+ _is_minimum_cib_schema_version(*version_tuple),
+ (
+ "Pacemaker supported CIB schema version is too low (current: "
+ "{current_version}, must be >= {minimal_version}) to test {feature}"
+ ).format(
+ current_version=format_version(current_version),
+ minimal_version=format_version(version_tuple),
+ feature=feature,
+ ),
+ )
+
+
+def skip_unless_crm_rule():
+ return skip_unless_pacemaker_version(
+ (2, 0, 2), "listing of constraints that might be expired"
+ )
+
+
def skip_unless_pacemaker_supports_bundle():
return skip_unless_pacemaker_features(
(3, 1, 0), "bundle resources with promoted-max attribute"
)
+def skip_unless_pacemaker_supports_rsc_and_op_rules():
+ return skip_unless_cib_schema_version(
+ (3, 4, 0), "rsc_expression and op_expression elements in rule elements"
+ )
+
+
def skip_if_service_enabled(service_name):
return skipUnless(
not is_service_enabled(runner, service_name),
diff --git a/pcsd/capabilities.xml b/pcsd/capabilities.xml
index daf23e5a..6e1886cb 100644
--- a/pcsd/capabilities.xml
+++ b/pcsd/capabilities.xml
@@ -964,6 +964,21 @@
pcs commands: resource op defaults
</description>
</capability>
+ <capability id="pcmk.properties.operation-defaults.multiple" in-pcs="1" in-pcsd="0">
+ <description>
+ Support for managing multiple sets of resource operations defaults.
+
+ pcs commands: resource op defaults set create | delete | remove | update
+ </description>
+ </capability>
+ <capability id="pcmk.properties.operation-defaults.rule-rsc-op" in-pcs="1" in-pcsd="0">
+ <description>
+ Support for rules with 'resource' and 'op' expressions in sets of
+ resource operations defaults.
+
+ pcs commands: resource op defaults set create
+ </description>
+ </capability>
<capability id="pcmk.properties.resource-defaults" in-pcs="1" in-pcsd="0">
<description>
Show and set resources defaults, can set multiple defaults at once.
@@ -971,6 +986,21 @@
pcs commands: resource defaults
</description>
</capability>
+ <capability id="pcmk.properties.resource-defaults.multiple" in-pcs="1" in-pcsd="0">
+ <description>
+ Support for managing multiple sets of resources defaults.
+
+ pcs commands: resource defaults set create | delete | remove | update
+ </description>
+ </capability>
+ <capability id="pcmk.properties.resource-defaults.rule-rsc-op" in-pcs="1" in-pcsd="0">
+ <description>
+ Support for rules with 'resource' and 'op' expressions in sets of
+ resources defaults.
+
+ pcs commands: resource defaults set create
+ </description>
+ </capability>
diff --git a/test/centos8/Dockerfile b/test/centos8/Dockerfile
index bcdfadef..753f0ca7 100644
--- a/test/centos8/Dockerfile
+++ b/test/centos8/Dockerfile
@@ -12,6 +12,7 @@ RUN dnf install -y \
python3-pip \
python3-pycurl \
python3-pyOpenSSL \
+ python3-pyparsing \
# ruby
ruby \
ruby-devel \
diff --git a/test/fedora30/Dockerfile b/test/fedora30/Dockerfile
index 60aad892..7edbfe5b 100644
--- a/test/fedora30/Dockerfile
+++ b/test/fedora30/Dockerfile
@@ -9,6 +9,7 @@ RUN dnf install -y \
python3-mock \
python3-pycurl \
python3-pyOpenSSL \
+ python3-pyparsing \
# ruby
ruby \
ruby-devel \
diff --git a/test/fedora31/Dockerfile b/test/fedora31/Dockerfile
index eb24bb1c..6750e222 100644
--- a/test/fedora31/Dockerfile
+++ b/test/fedora31/Dockerfile
@@ -10,6 +10,7 @@ RUN dnf install -y \
python3-pip \
python3-pycurl \
python3-pyOpenSSL \
+ python3-pyparsing \
# ruby
ruby \
ruby-devel \
diff --git a/test/fedora32/Dockerfile b/test/fedora32/Dockerfile
index 61a0a439..c6cc2146 100644
--- a/test/fedora32/Dockerfile
+++ b/test/fedora32/Dockerfile
@@ -11,6 +11,7 @@ RUN dnf install -y \
python3-pip \
python3-pycurl \
python3-pyOpenSSL \
+ python3-pyparsing \
# ruby
ruby \
ruby-devel \
--
2.25.4