From 419aa72eea840ba4a6d5bf30ea8d7c80251c1f38 Mon Sep 17 00:00:00 2001 From: zouzhimin Date: Sat, 31 Aug 2024 14:16:34 +0800 Subject: [PATCH] sync 2403 pr --- ...-constraints-in-a-non-deprecated-way.patch | 58 + ...verting-functions-for-resources-stat.patch | 3141 +++++++++++++++++ ...rt-fix-booth-destroy-for-arbitrators.patch | 243 ++ ...ix-stdout-wrapping-to-terminal-width.patch | 49 + fixes-after-review.patch | 3129 ++++++++++++++++ increase-a-timeout-in-a-test.patch | 28 + pcs.spec | 51 +- ...-instance-id-in-resource-status-dtos.patch | 812 +++++ update-crm_mon-schemas-for-tests.patch | 957 +++++ 9 files changed, 8461 insertions(+), 7 deletions(-) create mode 100644 Export-rule-constraints-in-a-non-deprecated-way.patch create mode 100644 add-dtos-and-converting-functions-for-resources-stat.patch create mode 100644 backport-fix-booth-destroy-for-arbitrators.patch create mode 100644 backport-fix-stdout-wrapping-to-terminal-width.patch create mode 100644 fixes-after-review.patch create mode 100644 increase-a-timeout-in-a-test.patch create mode 100644 store-clone-instance-id-in-resource-status-dtos.patch create mode 100644 update-crm_mon-schemas-for-tests.patch diff --git a/Export-rule-constraints-in-a-non-deprecated-way.patch b/Export-rule-constraints-in-a-non-deprecated-way.patch new file mode 100644 index 0000000..b0c1c00 --- /dev/null +++ b/Export-rule-constraints-in-a-non-deprecated-way.patch @@ -0,0 +1,58 @@ +From 4d375004dd11b7ddc4dd3f211c06008d71626dcf Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Tue, 30 Apr 2024 15:31:06 +0200 +Subject: [PATCH] export rule constraints in a non-deprecated way + +--- + pcs/cli/constraint/output/location.py | 4 ++-- + pcs_test/tier1/constraint/test_config.py | 8 ++++---- + 2 files changed, 6 insertions(+), 6 deletions(-) + +diff --git a/pcs/cli/constraint/output/location.py b/pcs/cli/constraint/output/location.py +index 25ac646a..141959d5 100644 +--- a/pcs/cli/constraint/output/location.py ++++ b/pcs/cli/constraint/output/location.py +@@ -190,7 +190,7 @@ def _add_rule_cmd(constraint_id: str, rule: CibRuleExpressionDto) -> list[str]: + indent( + [ + pairs_to_cmd([("id", rule.id)] + _rule_to_cmd_pairs(rule)), +- shlex.join(shlex.split(rule.as_string)), ++ shlex.quote(rule.as_string), + ], + indent_step=INDENT_STEP, + ) +@@ -221,7 +221,7 @@ def _plain_constraint_rule_to_cmd( + + _attributes_to_pairs(constraint_dto.attributes) + + _rule_to_cmd_pairs(first_rule) + ), +- shlex.join(shlex.split(first_rule.as_string)), ++ shlex.quote(first_rule.as_string), + ], + indent_step=INDENT_STEP, + ) +diff --git a/pcs_test/tier1/constraint/test_config.py b/pcs_test/tier1/constraint/test_config.py +index 1ce5a2a5..de39b3a5 100644 +--- a/pcs_test/tier1/constraint/test_config.py ++++ b/pcs_test/tier1/constraint/test_config.py +@@ -191,14 +191,14 @@ class ConstraintConfigCmdSpaceInDate(ConstraintConfigCmdMixin, TestCase): + ( + "pcs -- constraint location resource%R1 rule \\\n" + " id=location-R1-rule constraint-id=location-R1 score=INFINITY \\\n" +- " '#uname' eq node1 and date gt 2023-01-01T12:00 and " ++ " '#uname eq node1 and date gt 2023-01-01T12:00 and " + "date lt 2023-12-31T12:00 and date in_range 2023-01-01T12:00 " +- "to 2023-12-31T12:00;\n" ++ "to 2023-12-31T12:00';\n" + "pcs -- constraint rule add location-R1 \\\n" + " id=location-R1-rule-1 score=INFINITY \\\n" +- " '#uname' eq node1 and date gt 2023-01-01T12:00 and " ++ " '#uname eq node1 and date gt 2023-01-01T12:00 and " + "date lt 2023-12-31T12:00 and date in_range 2023-01-01T12:00 " +- "to 2023-12-31T12:00\n" ++ "to 2023-12-31T12:00'\n" + ), + ) + +-- +2.25.1 + diff --git a/add-dtos-and-converting-functions-for-resources-stat.patch b/add-dtos-and-converting-functions-for-resources-stat.patch new file mode 100644 index 0000000..e6acbcb --- /dev/null +++ b/add-dtos-and-converting-functions-for-resources-stat.patch @@ -0,0 +1,3141 @@ +From 713ede0d903f4a66a17ba30b627af921c3dbbb45 Mon Sep 17 00:00:00 2001 +From: Peter Romancik +Date: Fri, 2 Feb 2024 10:51:27 +0100 +Subject: [PATCH] add dtos and converting functions for resources status + +--- + pcs/Makefile.am | 2 + + pcs/cli/common/lib_wrapper.py | 1 + + pcs/common/const.py | 23 + + pcs/common/reports/codes.py | 22 + + pcs/common/reports/messages.py | 214 ++ + pcs/common/status_dto.py | 91 + + pcs/lib/commands/status.py | 13 + + pcs/lib/pacemaker/status.py | 509 +++++ + pcs_test/Makefile.am | 2 + + pcs_test/resources/crm_mon.all_resources.xml | 40 + + .../tier0/common/reports/test_messages.py | 142 ++ + pcs_test/tier0/lib/commands/test_status.py | 139 ++ + pcs_test/tier0/lib/pacemaker/test_status.py | 1741 +++++++++++++++++ + 13 files changed, 2939 insertions(+) + create mode 100644 pcs/common/status_dto.py + create mode 100644 pcs/lib/pacemaker/status.py + create mode 100644 pcs_test/resources/crm_mon.all_resources.xml + create mode 100644 pcs_test/tier0/lib/pacemaker/test_status.py + +diff --git a/pcs/Makefile.am b/pcs/Makefile.am +index ce10b49e..88ee8b7f 100644 +--- a/pcs/Makefile.am ++++ b/pcs/Makefile.am +@@ -179,6 +179,7 @@ EXTRA_DIST = \ + common/tools.py \ + common/types.py \ + common/validate.py \ ++ common/status_dto.py \ + config.py \ + constraint.py \ + daemon/app/common.py \ +@@ -363,6 +364,7 @@ EXTRA_DIST = \ + lib/pacemaker/live.py \ + lib/pacemaker/simulate.py \ + lib/pacemaker/state.py \ ++ lib/pacemaker/status.py \ + lib/pacemaker/values.py \ + lib/permissions/__init__.py \ + lib/permissions/checker.py \ +diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py +index 447cf9d8..2fd5b1b6 100644 +--- a/pcs/cli/common/lib_wrapper.py ++++ b/pcs/cli/common/lib_wrapper.py +@@ -448,6 +448,7 @@ def load_module(env, middleware_factory, name): + "full_cluster_status_plaintext": ( + status.full_cluster_status_plaintext + ), ++ "resources_status": status.resources_status, + }, + ) + +diff --git a/pcs/common/const.py b/pcs/common/const.py +index 311f5171..32175677 100644 +--- a/pcs/common/const.py ++++ b/pcs/common/const.py +@@ -3,6 +3,7 @@ from typing import NewType + from pcs.common.tools import Version + + PcmkRoleType = NewType("PcmkRoleType", str) ++PcmkStatusRoleType = NewType("PcmkStatusRoleType", str) + PcmkOnFailAction = NewType("PcmkOnFailAction", str) + PcmkAction = NewType("PcmkAction", str) + +@@ -13,6 +14,14 @@ PCMK_ROLE_PROMOTED = PcmkRoleType("Promoted") + PCMK_ROLE_UNPROMOTED = PcmkRoleType("Unpromoted") + PCMK_ROLE_PROMOTED_LEGACY = PcmkRoleType("Master") + PCMK_ROLE_UNPROMOTED_LEGACY = PcmkRoleType("Slave") ++PCMK_ROLE_UNKNOWN = PcmkRoleType("Unknown") ++PCMK_STATUS_ROLE_STARTED = PcmkStatusRoleType("Started") ++PCMK_STATUS_ROLE_STOPPED = PcmkStatusRoleType("Stopped") ++PCMK_STATUS_ROLE_PROMOTED = PcmkStatusRoleType("Promoted") ++PCMK_STATUS_ROLE_UNPROMOTED = PcmkStatusRoleType("Unpromoted") ++PCMK_STATUS_ROLE_STARTING = PcmkStatusRoleType("Starting") ++PCMK_STATUS_ROLE_STOPPING = PcmkStatusRoleType("Stopping") ++PCMK_STATUS_ROLE_UNKNOWN = PcmkStatusRoleType("Unknown") + PCMK_ON_FAIL_ACTION_IGNORE = PcmkOnFailAction("ignore") + PCMK_ON_FAIL_ACTION_BLOCK = PcmkOnFailAction("block") + PCMK_ON_FAIL_ACTION_DEMOTE = PcmkOnFailAction("demote") +@@ -29,6 +38,20 @@ PCMK_ROLES_RUNNING = ( + (PCMK_ROLE_STARTED,) + PCMK_ROLES_PROMOTED + PCMK_ROLES_UNPROMOTED + ) + PCMK_ROLES = (PCMK_ROLE_STOPPED,) + PCMK_ROLES_RUNNING ++PCMK_STATUS_ROLES_RUNNING = ( ++ PCMK_STATUS_ROLE_STARTED, ++ PCMK_STATUS_ROLE_PROMOTED, ++ PCMK_STATUS_ROLE_UNPROMOTED, ++) ++PCMK_STATUS_ROLES_PENDING = ( ++ PCMK_STATUS_ROLE_STARTING, ++ PCMK_STATUS_ROLE_STOPPING, ++) ++PCMK_STATUS_ROLES = ( ++ PCMK_STATUS_ROLES_RUNNING ++ + PCMK_STATUS_ROLES_PENDING ++ + (PCMK_STATUS_ROLE_STOPPED,) ++) + PCMK_ACTION_START = PcmkAction("start") + PCMK_ACTION_STOP = PcmkAction("stop") + PCMK_ACTION_PROMOTE = PcmkAction("promote") +diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py +index 188295f2..417a3f4a 100644 +--- a/pcs/common/reports/codes.py ++++ b/pcs/common/reports/codes.py +@@ -156,6 +156,28 @@ CLUSTER_RESTART_REQUIRED_TO_APPLY_CHANGES = M( + CLUSTER_SETUP_SUCCESS = M("CLUSTER_SETUP_SUCCESS") + CLUSTER_START_STARTED = M("CLUSTER_START_STARTED") + CLUSTER_START_SUCCESS = M("CLUSTER_START_SUCCESS") ++CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS = M( ++ "CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS" ++) ++CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT = M( ++ "CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT" ++) ++CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT = M( ++ "CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT" ++) ++CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE = M( ++ "CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE" ++) ++CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER = M( ++ "CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER" ++) ++CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS = M( ++ "CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS" ++) ++CLUSTER_STATUS_CLONE_MIXED_MEMBERS = M("CLUSTER_STATUS_CLONE_MIXED_MEMBERS") ++CLUSTER_STATUS_EMPTY_NODE_NAME = M("CLUSTER_STATUS_EMPTY_NODE_NAME") ++CLUSTER_STATUS_UNEXPECTED_MEMBER = M("CLUSTER_STATUS_UNEXPECTED_MEMBER") ++CLUSTER_STATUS_UNKNOWN_PCMK_ROLE = M("CLUSTER_STATUS_UNKNOWN_PCMK_ROLE") + CLUSTER_UUID_ALREADY_SET = M("CLUSTER_UUID_ALREADY_SET") + CLUSTER_WILL_BE_DESTROYED = M("CLUSTER_WILL_BE_DESTROYED") + COMMAND_INVALID_PAYLOAD = M("COMMAND_INVALID_PAYLOAD") +diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py +index e37cdf7c..1e98711c 100644 +--- a/pcs/common/reports/messages.py ++++ b/pcs/common/reports/messages.py +@@ -3276,6 +3276,220 @@ class BadClusterStateFormat(ReportItemMessage): + return "cannot load cluster status, xml does not conform to the schema" + + ++@dataclass(frozen=True) ++class ClusterStatusUnknownPcmkRole(ReportItemMessage): ++ """ ++ Value of pcmk role in the status xml is not valid ++ ++ role -- value of the role attribute ++ resource_id -- id of the resource ++ """ ++ ++ role: Optional[str] ++ resource_id: str ++ _code = codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE ++ ++ @property ++ def message(self) -> str: ++ return ( ++ "Attribute of resource with id '{id}' " ++ "contains {invalid} pcmk role{role}." ++ ).format( ++ id=self.resource_id, ++ invalid="empty" if not self.role else "invalid", ++ role=f" '{self.role}'" if self.role else "", ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusEmptyNodeName(ReportItemMessage): ++ """ ++ Resource in the status xml contains node with empty name ++ ++ resource_id -- id of the resource ++ """ ++ ++ resource_id: str ++ _code = codes.CLUSTER_STATUS_EMPTY_NODE_NAME ++ ++ @property ++ def message(self) -> str: ++ return ( ++ f"Resource with id '{self.resource_id}' contains node " ++ "with empty name." ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusUnexpectedMember(ReportItemMessage): ++ """ ++ Unexpected resource type is present in present as child element ++ in another resource type ++ ++ resource_id -- id of the outer resource ++ resource_type -- type of the outer resource ++ member_id -- id of the unexpected member ++ expected_type -- valid types for members ++ """ ++ ++ resource_id: str ++ resource_type: str ++ member_id: str ++ expected_types: list[str] ++ _code = codes.CLUSTER_STATUS_UNEXPECTED_MEMBER ++ ++ @property ++ def message(self) -> str: ++ return ( ++ f"Unexpected resource '{self.member_id}' inside of resource " ++ f"'{self.resource_id}' of type '{self.resource_type}'. " ++ f"Only resources of type {format_list(self.expected_types, '|')} " ++ f"can be in {self.resource_type}." ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusCloneMixedMembers(ReportItemMessage): ++ """ ++ Members of multiple types are present in a clone in the status xml ++ ++ member_id -- id of the unexpected member ++ clone_id -- id of the clone ++ """ ++ ++ clone_id: str ++ _code = codes.CLUSTER_STATUS_CLONE_MIXED_MEMBERS ++ ++ @property ++ def message(self) -> str: ++ return f"Primitive and group members mixed in clone '{self.clone_id}'." ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusCloneMembersDifferentIds(ReportItemMessage): ++ """ ++ Clone instances in crm_mon status xml have different ids ++ ++ clone_id -- id of the clone ++ """ ++ ++ clone_id: str ++ _code = codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS ++ ++ @property ++ def message(self) -> str: ++ return f"Members with different ids in clone '{self.clone_id}'." ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusBundleReplicaNoContainer(ReportItemMessage): ++ """ ++ Bundle replica is missing implicit container resource in the status xml ++ ++ bundle_id -- id of the bundle ++ replica_id -- id of the replica ++ """ ++ ++ bundle_id: str ++ replica_id: str ++ _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER ++ ++ @property ++ def message(self) -> str: ++ return ( ++ f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' " ++ "is missing implicit container resource." ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusBundleReplicaMissingRemote(ReportItemMessage): ++ """ ++ Bundle replica is missing implicit pacemaker remote resource ++ in the status xml ++ ++ bundle_id -- id of the bundle ++ replica_id -- id of the replica ++ """ ++ ++ bundle_id: str ++ replica_id: str ++ _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE ++ ++ @property ++ def message(self) -> str: ++ return ( ++ f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' is " ++ "missing implicit pacemaker remote resource while it must be " ++ "present." ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusBundleReplicaInvalidCount(ReportItemMessage): ++ """ ++ Bundle replica is has invalid number of members in the status xml ++ ++ bundle_id -- id of the bundle ++ replica_id -- id of the replica ++ """ ++ ++ bundle_id: str ++ replica_id: str ++ _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT ++ ++ @property ++ def message(self) -> str: ++ return ( ++ f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' has " ++ f"invalid number of members. Expecting 2-4 members." ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage): ++ """ ++ Member of bundle in cluster status xml has the same id as one of ++ the implicit resources ++ ++ bundle_id -- id of the bundle ++ member_id -- id if the bundle member ++ """ ++ ++ bundle_id: str ++ bad_ids: list[str] ++ _code = codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT ++ ++ @property ++ def message(self) -> str: ++ return ( ++ "Skipping bundle '{bundle_id}': {resource_word} " ++ "{bad_ids} {has} the same id as some of the " ++ "implicit bundle resources." ++ ).format( ++ bundle_id=self.bundle_id, ++ resource_word=format_plural(self.bad_ids, "resource"), ++ bad_ids=format_list(self.bad_ids), ++ has=format_plural(self.bad_ids, "has"), ++ ) ++ ++ ++@dataclass(frozen=True) ++class ClusterStatusBundleDifferentReplicas(ReportItemMessage): ++ """ ++ Replicas of bundle are different in the cluster status xml ++ ++ bundle_id -- id of the bundle ++ """ ++ ++ bundle_id: str ++ _code = codes.CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS ++ ++ @property ++ def message(self) -> str: ++ return f"Replicas of bundle '{self.bundle_id}' are not the same." ++ ++ + @dataclass(frozen=True) + class WaitForIdleStarted(ReportItemMessage): + """ +diff --git a/pcs/common/status_dto.py b/pcs/common/status_dto.py +new file mode 100644 +index 00000000..dcc94eca +--- /dev/null ++++ b/pcs/common/status_dto.py +@@ -0,0 +1,91 @@ ++from dataclasses import dataclass ++from typing import ( ++ Optional, ++ Sequence, ++ Union, ++) ++ ++from pcs.common.const import ( ++ PcmkRoleType, ++ PcmkStatusRoleType, ++) ++from pcs.common.interface.dto import DataTransferObject ++ ++ ++@dataclass(frozen=True) ++class PrimitiveStatusDto(DataTransferObject): ++ # pylint: disable=too-many-instance-attributes ++ resource_id: str ++ resource_agent: str ++ role: PcmkStatusRoleType ++ target_role: Optional[PcmkRoleType] ++ active: bool ++ orphaned: bool ++ blocked: bool ++ maintenance: bool ++ description: Optional[str] ++ failed: bool ++ managed: bool ++ failure_ignored: bool ++ node_names: list[str] ++ pending: Optional[str] ++ locked_to: Optional[str] ++ ++ ++@dataclass(frozen=True) ++class GroupStatusDto(DataTransferObject): ++ resource_id: str ++ maintenance: bool ++ description: Optional[str] ++ managed: bool ++ disabled: bool ++ members: Sequence[PrimitiveStatusDto] ++ ++ ++@dataclass(frozen=True) ++class CloneStatusDto(DataTransferObject): ++ # pylint: disable=too-many-instance-attributes ++ resource_id: str ++ multi_state: bool ++ unique: bool ++ maintenance: bool ++ description: Optional[str] ++ managed: bool ++ disabled: bool ++ failed: bool ++ failure_ignored: bool ++ target_role: Optional[PcmkRoleType] ++ instances: Union[Sequence[PrimitiveStatusDto], Sequence[GroupStatusDto]] ++ ++ ++@dataclass(frozen=True) ++class BundleReplicaStatusDto(DataTransferObject): ++ replica_id: str ++ member: Optional[PrimitiveStatusDto] ++ remote: Optional[PrimitiveStatusDto] ++ container: PrimitiveStatusDto ++ ip_address: Optional[PrimitiveStatusDto] ++ ++ ++@dataclass(frozen=True) ++class BundleStatusDto(DataTransferObject): ++ # pylint: disable=too-many-instance-attributes ++ resource_id: str ++ type: str ++ image: str ++ unique: bool ++ maintenance: bool ++ description: Optional[str] ++ managed: bool ++ failed: bool ++ replicas: Sequence[BundleReplicaStatusDto] ++ ++ ++AnyResourceStatusDto = Union[ ++ PrimitiveStatusDto, GroupStatusDto, CloneStatusDto, BundleStatusDto ++] ++ ++ ++@dataclass(frozen=True) ++class ResourcesStatusDto(DataTransferObject): ++ resources: Sequence[AnyResourceStatusDto] +diff --git a/pcs/lib/commands/status.py b/pcs/lib/commands/status.py +index ec7848d1..8b644ac1 100644 +--- a/pcs/lib/commands/status.py ++++ b/pcs/lib/commands/status.py +@@ -17,6 +17,7 @@ from pcs.common.node_communicator import Communicator + from pcs.common.reports import ReportProcessor + from pcs.common.reports.item import ReportItem + from pcs.common.services.interfaces import ServiceManagerInterface ++from pcs.common.status_dto import ResourcesStatusDto + from pcs.common.str_tools import ( + format_list, + indent, +@@ -48,6 +49,7 @@ from pcs.lib.pacemaker.live import ( + get_cluster_status_xml_raw, + get_ticket_status_text, + ) ++from pcs.lib.pacemaker.status import status_xml_to_dto + from pcs.lib.resource_agent.const import STONITH_ACTION_REPLACED_BY + from pcs.lib.sbd import get_sbd_service_name + +@@ -69,6 +71,17 @@ def pacemaker_status_xml(env: LibraryEnvironment) -> str: + raise LibraryError(output=stdout) + + ++def resources_status(env: LibraryEnvironment) -> ResourcesStatusDto: ++ """ ++ Return pacemaker status of configured resources as DTO ++ ++ env -- LibraryEnvironment ++ """ ++ status_xml = env.get_cluster_state() ++ ++ return status_xml_to_dto(env.report_processor, status_xml) ++ ++ + def full_cluster_status_plaintext( + env: LibraryEnvironment, + hide_inactive_resources: bool = False, +diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py +new file mode 100644 +index 00000000..722ce03f +--- /dev/null ++++ b/pcs/lib/pacemaker/status.py +@@ -0,0 +1,509 @@ ++from typing import ( ++ Optional, ++ Sequence, ++ Union, ++ cast, ++) ++ ++from lxml.etree import _Element ++ ++from pcs.common import reports ++from pcs.common.const import ( ++ PCMK_ROLE_UNKNOWN, ++ PCMK_ROLES, ++ PCMK_STATUS_ROLE_UNKNOWN, ++ PCMK_STATUS_ROLES, ++ PcmkRoleType, ++ PcmkStatusRoleType, ++) ++from pcs.common.reports import ReportProcessor ++from pcs.common.status_dto import ( ++ AnyResourceStatusDto, ++ BundleReplicaStatusDto, ++ BundleStatusDto, ++ CloneStatusDto, ++ GroupStatusDto, ++ PrimitiveStatusDto, ++ ResourcesStatusDto, ++) ++from pcs.lib.errors import LibraryError ++from pcs.lib.pacemaker.values import is_true ++ ++_PRIMITIVE_TAG = "resource" ++_GROUP_TAG = "group" ++_CLONE_TAG = "clone" ++_BUNDLE_TAG = "bundle" ++_REPLICA_TAG = "replica" ++ ++ ++def _primitive_to_dto( ++ reporter: ReportProcessor, ++ primitive_el: _Element, ++ remove_clone_suffix: bool = False, ++) -> PrimitiveStatusDto: ++ resource_id = _get_resource_id(reporter, primitive_el) ++ if remove_clone_suffix: ++ resource_id = _remove_clone_suffix(resource_id) ++ ++ role = _get_role(reporter, primitive_el, resource_id) ++ target_role = _get_target_role(reporter, primitive_el, resource_id) ++ ++ node_names = [ ++ str(node.get("name")) for node in primitive_el.iterfind("node") ++ ] ++ ++ if node_names and any(not name for name in node_names): ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusEmptyNodeName(resource_id) ++ ) ++ ) ++ ++ if reporter.has_errors: ++ raise LibraryError() ++ ++ return PrimitiveStatusDto( ++ resource_id, ++ str(primitive_el.get("resource_agent")), ++ role, ++ target_role, ++ is_true(primitive_el.get("active", "false")), ++ is_true(primitive_el.get("orphaned", "false")), ++ is_true(primitive_el.get("blocked", "false")), ++ is_true(primitive_el.get("maintenance", "false")), ++ primitive_el.get("description"), ++ is_true(primitive_el.get("failed", "false")), ++ is_true(primitive_el.get("managed", "false")), ++ is_true(primitive_el.get("failure_ignored", "false")), ++ [str(node.get("name")) for node in primitive_el.iterfind("node")], ++ primitive_el.get("pending"), ++ primitive_el.get("locked_to"), ++ ) ++ ++ ++def _group_to_dto( ++ reporter: ReportProcessor, ++ group_el: _Element, ++ remove_clone_suffix: bool = False, ++) -> GroupStatusDto: ++ # clone suffix is added even when the clone is non unique ++ group_id = _remove_clone_suffix(_get_resource_id(reporter, group_el)) ++ members = [] ++ ++ for member in group_el: ++ if member.tag == _PRIMITIVE_TAG: ++ members.append( ++ _primitive_to_dto(reporter, member, remove_clone_suffix) ++ ) ++ else: ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusUnexpectedMember( ++ group_id, "group", str(member.get("id")), ["primitive"] ++ ) ++ ) ++ ) ++ ++ if reporter.has_errors: ++ raise LibraryError() ++ ++ return GroupStatusDto( ++ group_id, ++ is_true(group_el.get("maintenance", "false")), ++ group_el.get("description"), ++ is_true(group_el.get("managed", "false")), ++ is_true(group_el.get("disabled", "false")), ++ members, ++ ) ++ ++ ++def _clone_to_dto( ++ reporter: ReportProcessor, ++ clone_el: _Element, ++ _remove_clone_suffix: bool = False, ++) -> CloneStatusDto: ++ clone_id = _get_resource_id(reporter, clone_el) ++ is_unique = is_true(clone_el.get("unique", "false")) ++ ++ target_role = _get_target_role(reporter, clone_el, clone_id) ++ ++ primitives = [] ++ groups = [] ++ ++ for member in clone_el: ++ if member.tag == _PRIMITIVE_TAG: ++ primitives.append(_primitive_to_dto(reporter, member, is_unique)) ++ elif member.tag == _GROUP_TAG: ++ groups.append(_group_to_dto(reporter, member, is_unique)) ++ else: ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusUnexpectedMember( ++ clone_id, ++ "clone", ++ str(member.get("id")), ++ ["primitive", "group"], ++ ) ++ ) ++ ) ++ ++ reporter.report_list( ++ _validate_mixed_instance_types(primitives, groups, clone_id) ++ ) ++ ++ instances: Union[list[PrimitiveStatusDto], list[GroupStatusDto]] ++ if primitives: ++ reporter.report_list( ++ _validate_primitive_instance_ids(primitives, clone_id) ++ ) ++ instances = primitives ++ else: ++ reporter.report_list(_validate_group_instance_ids(groups, clone_id)) ++ instances = groups ++ ++ if reporter.has_errors: ++ raise LibraryError() ++ ++ return CloneStatusDto( ++ clone_id, ++ is_true(clone_el.get("multi_state", "false")), ++ is_unique, ++ is_true(clone_el.get("maintenance", "false")), ++ clone_el.get("description"), ++ is_true(clone_el.get("managed", "false")), ++ is_true(clone_el.get("disabled", "false")), ++ is_true(clone_el.get("failed", "false")), ++ is_true(clone_el.get("failure_ignored", "false")), ++ target_role, ++ instances, ++ ) ++ ++ ++def _bundle_to_dto( ++ reporter: ReportProcessor, ++ bundle_el: _Element, ++ _remove_clone_suffix: bool = False, ++) -> Optional[BundleStatusDto]: ++ bundle_id = _get_resource_id(reporter, bundle_el) ++ bundle_type = str(bundle_el.get("type")) ++ ++ replicas = [] ++ for replica in bundle_el.iterfind(_REPLICA_TAG): ++ replica_dto = _replica_to_dto(reporter, replica, bundle_id, bundle_type) ++ if replica_dto is None: ++ # skip this bundle in status ++ return None ++ replicas.append(replica_dto) ++ ++ reporter.report_list(_validate_replicas(replicas, bundle_id)) ++ ++ if reporter.has_errors: ++ raise LibraryError() ++ ++ return BundleStatusDto( ++ bundle_id, ++ bundle_type, ++ str(bundle_el.get("image")), ++ is_true(bundle_el.get("unique", "false")), ++ is_true(bundle_el.get("maintenance", "false")), ++ bundle_el.get("description"), ++ is_true(bundle_el.get("managed", "false")), ++ is_true(bundle_el.get("failed", "false")), ++ replicas, ++ ) ++ ++ ++_TAG_TO_FUNCTION = { ++ _PRIMITIVE_TAG: _primitive_to_dto, ++ _GROUP_TAG: _group_to_dto, ++ _CLONE_TAG: _clone_to_dto, ++ _BUNDLE_TAG: _bundle_to_dto, ++} ++ ++ ++def status_xml_to_dto( ++ reporter: ReportProcessor, status: _Element ++) -> ResourcesStatusDto: ++ """ ++ Return dto containing status of configured resources in the cluster ++ ++ reporter -- ReportProcessor ++ status -- status xml document from crm_mon, validated using ++ the appropriate rng schema ++ """ ++ resources = cast(list[_Element], status.xpath("resources/*")) ++ ++ resource_dtos = [ ++ _TAG_TO_FUNCTION[resource.tag](reporter, resource) ++ for resource in resources ++ if resource.tag in _TAG_TO_FUNCTION ++ ] ++ ++ if reporter.has_errors: ++ raise LibraryError() ++ ++ return ResourcesStatusDto( ++ cast( ++ list[AnyResourceStatusDto], ++ [dto for dto in resource_dtos if dto is not None], ++ ) ++ ) ++ ++ ++def _get_resource_id(reporter: ReportProcessor, resource: _Element) -> str: ++ resource_id = resource.get("id") ++ if not resource_id: ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.InvalidIdIsEmpty("resource id") ++ ) ++ ) ++ return str(resource_id) ++ ++ ++def _get_role( ++ reporter: ReportProcessor, resource: _Element, resource_id: str ++) -> PcmkStatusRoleType: ++ role = resource.get("role") ++ if role is None or role not in PCMK_STATUS_ROLES: ++ reporter.report( ++ reports.ReportItem.warning( ++ reports.messages.ClusterStatusUnknownPcmkRole(role, resource_id) ++ ) ++ ) ++ return PCMK_STATUS_ROLE_UNKNOWN ++ return PcmkStatusRoleType(role) ++ ++ ++def _get_target_role( ++ reporter: ReportProcessor, resource: _Element, resource_id: str ++) -> Optional[PcmkRoleType]: ++ target_role = resource.get("target_role") ++ if target_role is None: ++ return None ++ if target_role not in PCMK_ROLES: ++ reporter.report( ++ reports.ReportItem.warning( ++ reports.messages.ClusterStatusUnknownPcmkRole( ++ target_role, resource_id ++ ) ++ ) ++ ) ++ return PCMK_ROLE_UNKNOWN ++ return PcmkRoleType(target_role) ++ ++ ++def _remove_clone_suffix(resource_id: str) -> str: ++ if ":" in resource_id: ++ return resource_id.rsplit(":", 1)[0] ++ return resource_id ++ ++ ++def _validate_mixed_instance_types( ++ primitives: list[PrimitiveStatusDto], ++ groups: list[GroupStatusDto], ++ clone_id: str, ++) -> reports.ReportItemList: ++ if primitives and groups: ++ return [ ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusCloneMixedMembers(clone_id) ++ ) ++ ] ++ return [] ++ ++ ++def _validate_primitive_instance_ids( ++ instances: list[PrimitiveStatusDto], clone_id: str ++) -> reports.ReportItemList: ++ if len(set(res.resource_id for res in instances)) > 1: ++ return [ ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusCloneMembersDifferentIds(clone_id) ++ ) ++ ] ++ return [] ++ ++ ++def _validate_group_instance_ids( ++ instances: list[GroupStatusDto], clone_id: str ++) -> reports.ReportItemList: ++ group_ids = set(group.resource_id for group in instances) ++ children_ids = set( ++ tuple(child.resource_id for child in group.members) ++ for group in instances ++ ) ++ ++ if len(group_ids) > 1 or len(children_ids) > 1: ++ return [ ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusCloneMembersDifferentIds(clone_id) ++ ) ++ ] ++ return [] ++ ++ ++def _replica_to_dto( ++ reporter: ReportProcessor, ++ replica_el: _Element, ++ bundle_id: str, ++ bundle_type: str, ++) -> Optional[BundleReplicaStatusDto]: ++ replica_id = str(replica_el.get("id")) ++ ++ resources = [ ++ _primitive_to_dto(reporter, resource) ++ for resource in replica_el.iterfind(_PRIMITIVE_TAG) ++ ] ++ ++ duplicate_ids = _find_duplicate_ids(resources) ++ if duplicate_ids: ++ reporter.report( ++ reports.ReportItem.warning( ++ reports.messages.ClusterStatusBundleMemberIdAsImplicit( ++ bundle_id, duplicate_ids ++ ) ++ ) ++ ) ++ return None ++ ++ # TODO pacemaker will probably add prefix ++ # "pcmk-internal" to all implicit resources ++ ++ container_resource = _get_implicit_resource( ++ resources, ++ f"{bundle_id}-{bundle_type}-{replica_id}", ++ True, ++ f"ocf:heartbeat:{bundle_type}", ++ ) ++ ++ if container_resource is None: ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusBundleReplicaNoContainer( ++ bundle_id, replica_id ++ ) ++ ) ++ ) ++ raise LibraryError() ++ ++ remote_resource = _get_implicit_resource( ++ resources, f"{bundle_id}-{replica_id}", True, "ocf:pacemaker:remote" ++ ) ++ ++ # implicit ip address resource might be present ++ ip_resource = None ++ if (remote_resource is not None and len(resources) == 2) or ( ++ remote_resource is None and len(resources) == 1 ++ ): ++ ip_resource = _get_implicit_resource( ++ resources, f"{bundle_id}-ip-", False, "ocf:heartbeat:IPaddr2" ++ ) ++ ++ if remote_resource is None and resources: ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusBundleReplicaMissingRemote( ++ bundle_id, replica_id ++ ) ++ ) ++ ) ++ raise LibraryError() ++ ++ member = None ++ if remote_resource: ++ if len(resources) == 1: ++ member = resources[0] ++ else: ++ reporter.report( ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusBundleReplicaInvalidCount( ++ bundle_id, replica_id ++ ) ++ ) ++ ) ++ raise LibraryError() ++ ++ return BundleReplicaStatusDto( ++ replica_id, ++ member, ++ remote_resource, ++ container_resource, ++ ip_resource, ++ ) ++ ++ ++def _find_duplicate_ids(resources: Sequence[AnyResourceStatusDto]) -> list[str]: ++ seen = set() ++ duplicates = [] ++ for resource in resources: ++ if resource.resource_id in seen: ++ duplicates.append(resource.resource_id) ++ else: ++ seen.add(resource.resource_id) ++ return duplicates ++ ++ ++def _get_implicit_resource( ++ primitives: list[PrimitiveStatusDto], ++ expected_id: str, ++ exact_match: bool, ++ resource_agent: str, ++) -> Optional[PrimitiveStatusDto]: ++ for primitive in primitives: ++ matching_id = ( ++ exact_match ++ and primitive.resource_id == expected_id ++ or not exact_match ++ and primitive.resource_id.startswith(expected_id) ++ ) ++ ++ if matching_id and primitive.resource_agent == resource_agent: ++ primitives.remove(primitive) ++ return primitive ++ ++ return None ++ ++ ++def _validate_replicas( ++ replicas: Sequence[BundleReplicaStatusDto], bundle_id: str ++) -> reports.ReportItemList: ++ if not replicas: ++ return [] ++ ++ member = replicas[0].member ++ ip = replicas[0].ip_address ++ container = replicas[0].container ++ ++ for replica in replicas: ++ if ( ++ not _cmp_replica_members(member, replica.member, True) ++ or not _cmp_replica_members(ip, replica.ip_address, False) ++ or not _cmp_replica_members(container, replica.container, False) ++ ): ++ return [ ++ reports.ReportItem.error( ++ reports.messages.ClusterStatusBundleDifferentReplicas( ++ bundle_id ++ ) ++ ) ++ ] ++ return [] ++ ++ ++def _cmp_replica_members( ++ left: Optional[PrimitiveStatusDto], ++ right: Optional[PrimitiveStatusDto], ++ compare_ids: bool, ++) -> bool: ++ if left is None and right is None: ++ return True ++ if left is None: ++ return False ++ if right is None: ++ return False ++ ++ if left.resource_agent != right.resource_agent: ++ return False ++ ++ return not compare_ids or left.resource_id == right.resource_id +diff --git a/pcs_test/Makefile.am b/pcs_test/Makefile.am +index 32ac5eee..f036ded5 100644 +--- a/pcs_test/Makefile.am ++++ b/pcs_test/Makefile.am +@@ -32,6 +32,7 @@ EXTRA_DIST = \ + resources/corosync-qdevice.conf \ + resources/corosync-some-node-names.conf \ + resources/crm_mon.minimal.xml \ ++ resources/crm_mon.all_resources.xml \ + resources/fenced_metadata.xml \ + resources/schedulerd_metadata.xml \ + resources/pcmk_api_rng/api-result.rng \ +@@ -322,6 +323,7 @@ EXTRA_DIST = \ + tier0/lib/pacemaker/test_live.py \ + tier0/lib/pacemaker/test_simulate.py \ + tier0/lib/pacemaker/test_state.py \ ++ tier0/lib/pacemaker/test_status.py \ + tier0/lib/pacemaker/test_values.py \ + tier0/lib/permissions/__init__.py \ + tier0/lib/permissions/config/__init__.py \ +diff --git a/pcs_test/resources/crm_mon.all_resources.xml b/pcs_test/resources/crm_mon.all_resources.xml +new file mode 100644 +index 00000000..e493d308 +--- /dev/null ++++ b/pcs_test/resources/crm_mon.all_resources.xml +@@ -0,0 +1,40 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py +index 58a70a37..b60360e4 100644 +--- a/pcs_test/tier0/common/reports/test_messages.py ++++ b/pcs_test/tier0/common/reports/test_messages.py +@@ -5816,3 +5816,145 @@ class CannotCreateDefaultClusterPropertySet(NameBuildTest): + "cib-bootstrap-options" + ), + ) ++ ++ ++class ClusterStatusBundleDifferentReplicas(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ "Replicas of bundle 'bundle' are not the same.", ++ reports.ClusterStatusBundleDifferentReplicas("bundle"), ++ ) ++ ++ ++class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ ( ++ "Skipping bundle 'bundle': resource 'test' has " ++ "the same id as some of the implicit bundle resources." ++ ), ++ reports.ClusterStatusBundleMemberIdAsImplicit("bundle", ["test"]), ++ ) ++ ++ def test_multiple_ids(self): ++ self.assert_message_from_report( ++ ( ++ "Skipping bundle 'bundle': resources 'test1', 'test2' have " ++ "the same id as some of the implicit bundle resources." ++ ), ++ reports.ClusterStatusBundleMemberIdAsImplicit( ++ "bundle", ["test1", "test2"] ++ ), ++ ) ++ ++ ++class ClusterStatusBundleReplicaInvalidCount(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ ( ++ "Replica '0' of bundle 'bundle' has invalid number of members. " ++ "Expecting 2-4 members." ++ ), ++ reports.ClusterStatusBundleReplicaInvalidCount("bundle", "0"), ++ ) ++ ++ ++class ClusterStatusBundleReplicaMissingRemote(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ ( ++ "Replica '0' of bundle 'bundle' is missing implicit pacemaker " ++ "remote resource while it must be present." ++ ), ++ reports.ClusterStatusBundleReplicaMissingRemote("bundle", "0"), ++ ) ++ ++ ++class ClusterStatusBundleReplicaNoContainer(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ ( ++ "Replica '0' of bundle 'bundle' is missing implicit container " ++ "resource." ++ ), ++ reports.ClusterStatusBundleReplicaNoContainer("bundle", "0"), ++ ) ++ ++ ++class ClusterStatusCloneMembersDifferentIds(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ "Members with different ids in clone 'clone'.", ++ reports.ClusterStatusCloneMembersDifferentIds("clone"), ++ ) ++ ++ ++class ClusterStatusCloneMixedMembers(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ "Primitive and group members mixed in clone 'clone'.", ++ reports.ClusterStatusCloneMixedMembers("clone"), ++ ) ++ ++ ++class ClusterStatusEmptyNodeName(NameBuildTest): ++ def test_message(self): ++ self.assert_message_from_report( ++ "Resource with id 'resource' contains node with empty name.", ++ reports.ClusterStatusEmptyNodeName("resource"), ++ ) ++ ++ ++class ClusterStatusUnexpectedMember(NameBuildTest): ++ def test_one_expected(self): ++ self.assert_message_from_report( ++ ( ++ "Unexpected resource 'member' inside of resource 'resource' of " ++ "type 'group'. Only resources of type 'primitive' " ++ "can be in group." ++ ), ++ reports.ClusterStatusUnexpectedMember( ++ resource_id="resource", ++ resource_type="group", ++ member_id="member", ++ expected_types=["primitive"], ++ ), ++ ) ++ ++ def test_multiple_expected(self): ++ self.assert_message_from_report( ++ ( ++ "Unexpected resource 'member' inside of resource 'resource' of " ++ "type 'clone'. Only resources of type 'group'|'primitive' " ++ "can be in clone." ++ ), ++ reports.ClusterStatusUnexpectedMember( ++ resource_id="resource", ++ resource_type="clone", ++ member_id="member", ++ expected_types=["primitive", "group"], ++ ), ++ ) ++ ++ ++class ClusterStatusUnknownPcmkRole(NameBuildTest): ++ def test_no_role(self): ++ self.assert_message_from_report( ++ "Attribute of resource with id 'resource' contains empty pcmk role.", ++ reports.ClusterStatusUnknownPcmkRole(None, "resource"), ++ ) ++ ++ def test_empty_role(self): ++ self.assert_message_from_report( ++ "Attribute of resource with id 'resource' contains empty pcmk role.", ++ reports.ClusterStatusUnknownPcmkRole("", "resource"), ++ ) ++ ++ def test_role(self): ++ self.assert_message_from_report( ++ ( ++ "Attribute of resource with id 'resource' contains invalid " ++ "pcmk role 'NotValidRole'." ++ ), ++ reports.ClusterStatusUnknownPcmkRole("NotValidRole", "resource"), ++ ) +diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py +index ce98ec63..a5a395b5 100644 +--- a/pcs_test/tier0/lib/commands/test_status.py ++++ b/pcs_test/tier0/lib/commands/test_status.py +@@ -7,7 +7,16 @@ from unittest import ( + + from pcs import settings + from pcs.common import file_type_codes ++from pcs.common.const import PCMK_STATUS_ROLE_STARTED + from pcs.common.reports import codes as report_codes ++from pcs.common.status_dto import ( ++ BundleReplicaStatusDto, ++ BundleStatusDto, ++ CloneStatusDto, ++ GroupStatusDto, ++ PrimitiveStatusDto, ++ ResourcesStatusDto, ++) + from pcs.lib.booth import constants + from pcs.lib.commands import status + from pcs.lib.errors import LibraryError +@@ -22,6 +31,7 @@ from pcs_test.tools.command_env.config_runner_pcmk import ( + RULE_EXPIRED_RETURNCODE, + RULE_IN_EFFECT_RETURNCODE, + ) ++from pcs_test.tools.misc import get_test_resource as rc + from pcs_test.tools.misc import read_test_resource as rc_read + + +@@ -1254,3 +1264,132 @@ class FullClusterStatusPlaintextBoothWarning(FullClusterStatusPlaintextBase): + ).encode("utf-8"), + ) + self._assert_status_output() ++ ++ ++def _fixture_primitive_resource_dto( ++ resource_id: str, resource_agent: str ++) -> PrimitiveStatusDto: ++ return PrimitiveStatusDto( ++ resource_id, ++ resource_agent, ++ PCMK_STATUS_ROLE_STARTED, ++ None, ++ True, ++ False, ++ False, ++ False, ++ None, ++ False, ++ True, ++ False, ++ ["node1"], ++ None, ++ None, ++ ) ++ ++ ++@mock.patch.object( ++ settings, ++ "pacemaker_api_result_schema", ++ rc("pcmk_api_rng/api-result.rng"), ++) ++class ResourcesStatus(TestCase): ++ def setUp(self): ++ self.env_assist, self.config = get_env_tools(self) ++ ++ def test_empty_resources(self): ++ self.config.runner.pcmk.load_state() ++ ++ result = status.resources_status(self.env_assist.get_env()) ++ self.assertEqual(result, ResourcesStatusDto([])) ++ ++ def test_bad_xml(self): ++ self.config.runner.pcmk.load_state( ++ resources=""" ++ ++ ++ ++ """, ++ ) ++ ++ self.env_assist.assert_raise_library_error( ++ lambda: status.resources_status( ++ self.env_assist.get_env(), ++ ), ++ [fixture.error(report_codes.BAD_CLUSTER_STATE_FORMAT)], ++ False, ++ ) ++ ++ def test_all_resources(self): ++ self.config.runner.pcmk.load_state( ++ filename=rc("crm_mon.all_resources.xml") ++ ) ++ ++ result = status.resources_status(self.env_assist.get_env()) ++ ++ self.assertTrue(len(result.resources) == 4) ++ self.assertEqual( ++ result.resources[0], ++ _fixture_primitive_resource_dto("dummy", "ocf:pacemaker:Dummy"), ++ ) ++ self.assertEqual( ++ result.resources[1], ++ GroupStatusDto( ++ "group", ++ False, ++ None, ++ True, ++ False, ++ members=[ ++ _fixture_primitive_resource_dto( ++ "grouped", "ocf:pacemaker:Dummy" ++ ) ++ ], ++ ), ++ ) ++ self.assertEqual( ++ result.resources[2], ++ CloneStatusDto( ++ "clone", ++ False, ++ False, ++ False, ++ None, ++ True, ++ False, ++ False, ++ False, ++ None, ++ instances=[ ++ _fixture_primitive_resource_dto( ++ "cloned", "ocf:pacemaker:Dummy" ++ ) ++ ], ++ ), ++ ) ++ self.assertEqual( ++ result.resources[3], ++ BundleStatusDto( ++ "bundle", ++ "podman", ++ "localhost/pcmktest:http", ++ False, ++ False, ++ None, ++ True, ++ False, ++ [ ++ BundleReplicaStatusDto( ++ "0", ++ None, ++ None, ++ _fixture_primitive_resource_dto( ++ "bundle-podman-0", "ocf:heartbeat:podman" ++ ), ++ _fixture_primitive_resource_dto( ++ "bundle-ip-192.168.122.250", "ocf:heartbeat:IPaddr2" ++ ), ++ ) ++ ], ++ ), ++ ) +diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py +new file mode 100644 +index 00000000..451fb584 +--- /dev/null ++++ b/pcs_test/tier0/lib/pacemaker/test_status.py +@@ -0,0 +1,1741 @@ ++# pylint: disable=too-many-lines ++from typing import ( ++ Optional, ++ Sequence, ++ Union, ++) ++from unittest import TestCase ++ ++from lxml import etree ++ ++from pcs.common import reports ++from pcs.common.const import ( ++ PCMK_ROLE_STARTED, ++ PCMK_ROLE_UNKNOWN, ++ PCMK_ROLES, ++ PCMK_STATUS_ROLE_STARTED, ++ PCMK_STATUS_ROLE_STOPPED, ++ PCMK_STATUS_ROLE_UNKNOWN, ++ PCMK_STATUS_ROLE_UNPROMOTED, ++ PCMK_STATUS_ROLES, ++ PCMK_STATUS_ROLES_PENDING, ++ PCMK_STATUS_ROLES_RUNNING, ++ PcmkStatusRoleType, ++) ++from pcs.common.status_dto import ( ++ BundleReplicaStatusDto, ++ BundleStatusDto, ++ CloneStatusDto, ++ GroupStatusDto, ++ PrimitiveStatusDto, ++ ResourcesStatusDto, ++) ++from pcs.lib.pacemaker import status ++ ++from pcs_test.tools import fixture ++from pcs_test.tools.assertions import ( ++ assert_raise_library_error, ++ assert_report_item_list_equal, ++) ++from pcs_test.tools.custom_mock import MockLibraryReportProcessor ++ ++ ++def fixture_primitive_xml( ++ resource_id: str = "resource", ++ resource_agent: str = "ocf:heartbeat:Dummy", ++ role: PcmkStatusRoleType = PCMK_STATUS_ROLE_STARTED, ++ target_role: Optional[str] = None, ++ managed: bool = True, ++ node_names: Sequence[str] = ("node1",), ++ add_optional_args: bool = False, ++) -> str: ++ target_role = ( ++ f'target_role="{target_role}"' if target_role is not None else "" ++ ) ++ active = role in PCMK_STATUS_ROLES_RUNNING ++ description = 'description="Test description"' if add_optional_args else "" ++ pending = 'pending="test"' if add_optional_args else "" ++ locked_to = 'locked_to="test"' if add_optional_args else "" ++ ++ nodes = "\n".join( ++ f'' ++ for (i, node) in enumerate(node_names) ++ ) ++ ++ return f""" ++ ++ {nodes} ++ ++ """ ++ ++ ++def fixture_primitive_dto( ++ resource_id: str = "resource", ++ resource_agent: str = "ocf:heartbeat:Dummy", ++ role: PcmkStatusRoleType = PCMK_STATUS_ROLE_STARTED, ++ target_role: Optional[str] = None, ++ managed: bool = True, ++ node_names: Sequence[str] = ("node1",), ++ add_optional_args: bool = False, ++) -> PrimitiveStatusDto: ++ return PrimitiveStatusDto( ++ resource_id, ++ resource_agent, ++ role, ++ target_role, ++ active=role in PCMK_STATUS_ROLES_RUNNING, ++ orphaned=False, ++ blocked=False, ++ maintenance=False, ++ description="Test description" if add_optional_args else None, ++ managed=managed, ++ failed=False, ++ failure_ignored=False, ++ node_names=list(node_names), ++ pending="test" if add_optional_args else None, ++ locked_to="test" if add_optional_args else None, ++ ) ++ ++ ++def fixture_group_xml( ++ resource_id: str = "resource-group", ++ description: Optional[str] = None, ++ members: Sequence[str] = (), ++) -> str: ++ description = ( ++ f'description="{description}"' if description is not None else "" ++ ) ++ members = "\n".join(members) ++ return f""" ++ ++ {members} ++ ++ """ ++ ++ ++def fixture_group_dto( ++ resource_id: str = "resource-group", ++ description: Optional[str] = None, ++ members: Sequence[PrimitiveStatusDto] = (), ++) -> GroupStatusDto: ++ return GroupStatusDto( ++ resource_id, ++ maintenance=False, ++ description=description, ++ managed=True, ++ disabled=False, ++ members=list(members), ++ ) ++ ++ ++def fixture_clone_xml( ++ resource_id: str = "resource-clone", ++ multi_state: bool = False, ++ unique: bool = False, ++ description: Optional[str] = None, ++ target_role: Optional[str] = None, ++ instances: Sequence[str] = (), ++) -> str: ++ description = ( ++ f'description="{description}"' if description is not None else "" ++ ) ++ target_role = ( ++ f'target_role="{target_role}"' if target_role is not None else "" ++ ) ++ instances = "\n".join(instances) ++ return f""" ++ ++ {instances} ++ ++ """ ++ ++ ++def fixture_clone_dto( ++ resource_id: str = "resource-clone", ++ multi_state: bool = False, ++ unique: bool = False, ++ description: Optional[str] = None, ++ target_role: Optional[str] = None, ++ instances: Union[ ++ Sequence[PrimitiveStatusDto], Sequence[GroupStatusDto] ++ ] = (), ++) -> CloneStatusDto: ++ return CloneStatusDto( ++ resource_id, ++ multi_state, ++ unique, ++ maintenance=False, ++ description=description, ++ managed=True, ++ disabled=False, ++ failed=False, ++ failure_ignored=False, ++ target_role=target_role, ++ instances=list(instances), ++ ) ++ ++ ++def fixture_replica_xml( ++ bundle_id: str = "resource-bundle", ++ replica_id: str = "0", ++ bundle_type: str = "podman", ++ ip: bool = False, ++ node_name: str = "node1", ++ member: Optional[str] = None, ++) -> str: ++ ip_resource = fixture_primitive_xml( ++ resource_id=f"{bundle_id}-ip-192.168.122.{replica_id}", ++ resource_agent="ocf:heartbeat:IPaddr2", ++ node_names=[node_name], ++ ) ++ remote_resource = fixture_primitive_xml( ++ resource_id=f"{bundle_id}-{replica_id}", ++ resource_agent="ocf:pacemaker:remote", ++ node_names=[node_name], ++ ) ++ container_resource = fixture_primitive_xml( ++ resource_id=f"{bundle_id}-{bundle_type}-{replica_id}", ++ resource_agent=f"ocf:heartbeat:{bundle_type}", ++ node_names=[node_name], ++ ) ++ return f""" ++ ++ {ip_resource if ip else ""} ++ {member if member is not None else ""} ++ {container_resource} ++ {remote_resource if member is not None else ""} ++ ++ """ ++ ++ ++def fixture_replica_dto( ++ bundle_id: str = "resource-bundle", ++ replica_id: str = "0", ++ bundle_type: str = "podman", ++ ip: bool = False, ++ node_name: str = "node1", ++ member: Optional[PrimitiveStatusDto] = None, ++) -> BundleReplicaStatusDto: ++ ip_resource = fixture_primitive_dto( ++ resource_id=f"{bundle_id}-ip-192.168.122.{replica_id}", ++ resource_agent="ocf:heartbeat:IPaddr2", ++ node_names=[node_name], ++ ) ++ remote_resource = fixture_primitive_dto( ++ resource_id=f"{bundle_id}-{replica_id}", ++ resource_agent="ocf:pacemaker:remote", ++ node_names=[node_name], ++ ) ++ container_resource = fixture_primitive_dto( ++ resource_id=f"{bundle_id}-{bundle_type}-{replica_id}", ++ resource_agent=f"ocf:heartbeat:{bundle_type}", ++ node_names=[node_name], ++ ) ++ return BundleReplicaStatusDto( ++ replica_id, ++ member, ++ remote_resource if member is not None else None, ++ container_resource, ++ ip_resource if ip else None, ++ ) ++ ++ ++def fixture_bundle_xml( ++ resource_id: str = "resource-bundle", replicas: Sequence[str] = () ++) -> str: ++ replicas = "\n".join(replicas) ++ return f""" ++ ++ {replicas} ++ ++ """ ++ ++ ++def fixture_bundle_dto( ++ resource_id: str = "resource-bundle", ++ replicas: Sequence[BundleReplicaStatusDto] = (), ++) -> BundleStatusDto: ++ return BundleStatusDto( ++ resource_id, ++ "podman", ++ "localhost/pcmktest:http", ++ False, ++ False, ++ None, ++ True, ++ False, ++ list(replicas), ++ ) ++ ++ ++def fixture_crm_mon_xml(resources: list[str]) -> str: ++ # we only care about the resources element, ++ # omitting other parts to make the string shorter ++ resources = "\n".join(resources) ++ return f""" ++ ++ ++ {resources} ++ ++ ++ ++ """ ++ ++ ++class TestPrimitiveStatusToDto(TestCase): ++ # pylint: disable=protected-access ++ def setUp(self): ++ self.report_processor = MockLibraryReportProcessor() ++ ++ def test_simple(self): ++ primitive_xml = etree.fromstring(fixture_primitive_xml()) ++ ++ result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ ++ self.assertEqual(result, fixture_primitive_dto()) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_empty_node_list(self): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(role=PCMK_STATUS_ROLE_STOPPED, node_names=[]) ++ ) ++ result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_primitive_dto(role=PCMK_STATUS_ROLE_STOPPED, node_names=[]), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_all_attributes(self): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml( ++ target_role=PCMK_STATUS_ROLE_STOPPED, add_optional_args=True ++ ) ++ ) ++ ++ result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_primitive_dto( ++ target_role=PCMK_STATUS_ROLE_STOPPED, add_optional_args=True ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_remove_clone_suffix(self): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(resource_id="resource:0") ++ ) ++ ++ result = status._primitive_to_dto( ++ self.report_processor, primitive_xml, True ++ ) ++ ++ self.assertEqual(result, fixture_primitive_dto()) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_running_on_multiple_nodes(self): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(node_names=["node1", "node2", "node3"]) ++ ) ++ ++ result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_primitive_dto(node_names=["node1", "node2", "node3"]), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_empty_node_name(self): ++ primitive_xml = etree.fromstring(fixture_primitive_xml(node_names=[""])) ++ ++ assert_raise_library_error( ++ lambda: status._primitive_to_dto( ++ self.report_processor, primitive_xml ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_EMPTY_NODE_NAME, ++ resource_id="resource", ++ ) ++ ], ++ ) ++ ++ def test_empty_resource_id(self): ++ primitive_xml = etree.fromstring(fixture_primitive_xml(resource_id="")) ++ ++ assert_raise_library_error( ++ lambda: status._primitive_to_dto( ++ self.report_processor, primitive_xml ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.INVALID_ID_IS_EMPTY, ++ id_description="resource id", ++ ) ++ ], ++ ) ++ ++ def test_role(self): ++ for role in PCMK_STATUS_ROLES: ++ with self.subTest(value=role): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(role=role) ++ ) ++ ++ result = status._primitive_to_dto( ++ self.report_processor, primitive_xml ++ ) ++ self.assertEqual(result, fixture_primitive_dto(role=role)) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_invalid_role(self): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(role="NotPcmkRole") ++ ) ++ ++ result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ ++ self.assertEqual( ++ result, fixture_primitive_dto(role=PCMK_STATUS_ROLE_UNKNOWN) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE, ++ role="NotPcmkRole", ++ resource_id="resource", ++ ) ++ ], ++ ) ++ ++ def test_target_role(self): ++ for role in PCMK_ROLES: ++ with self.subTest(value=role): ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(target_role=role) ++ ) ++ ++ result = status._primitive_to_dto( ++ self.report_processor, primitive_xml ++ ) ++ ++ self.assertEqual( ++ result, fixture_primitive_dto(target_role=role) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_invalid_target_role(self): ++ for value in PCMK_STATUS_ROLES_PENDING + ("NotPcmkRole",): ++ with self.subTest(value=value): ++ self.setUp() ++ primitive_xml = etree.fromstring( ++ fixture_primitive_xml(target_role=value) ++ ) ++ ++ result = status._primitive_to_dto( ++ self.report_processor, primitive_xml ++ ) ++ ++ self.assertEqual( ++ result, fixture_primitive_dto(target_role=PCMK_ROLE_UNKNOWN) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE, ++ role=value, ++ resource_id="resource", ++ ) ++ ], ++ ) ++ ++ ++class TestGroupStatusToDto(TestCase): ++ # pylint: disable=protected-access ++ def setUp(self): ++ self.report_processor = MockLibraryReportProcessor() ++ ++ def test_all_attributes(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml(description="Test description") ++ ) ++ ++ result = status._group_to_dto(self.report_processor, group_xml) ++ ++ self.assertEqual( ++ result, fixture_group_dto(description="Test description") ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_single_member(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml(members=[fixture_primitive_xml()]) ++ ) ++ ++ result = status._group_to_dto(self.report_processor, group_xml) ++ ++ self.assertEqual( ++ result, fixture_group_dto(members=[fixture_primitive_dto()]) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_multiple_members(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml( ++ members=[ ++ fixture_primitive_xml(resource_id="resource1"), ++ fixture_primitive_xml(resource_id="resource2"), ++ ] ++ ) ++ ) ++ ++ result = status._group_to_dto(self.report_processor, group_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_group_dto( ++ members=[ ++ fixture_primitive_dto(resource_id="resource1"), ++ fixture_primitive_dto(resource_id="resource2"), ++ ] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_multiple_members_different_state(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml( ++ members=[ ++ fixture_primitive_xml( ++ resource_id="resource1", ++ role=PCMK_STATUS_ROLE_STOPPED, ++ managed=False, ++ node_names=[], ++ ), ++ fixture_primitive_xml(resource_id="resource2"), ++ ] ++ ) ++ ) ++ ++ result = status._group_to_dto(self.report_processor, group_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_group_dto( ++ members=[ ++ fixture_primitive_dto( ++ resource_id="resource1", ++ role=PCMK_STATUS_ROLE_STOPPED, ++ managed=False, ++ node_names=[], ++ ), ++ fixture_primitive_dto(resource_id="resource2"), ++ ] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_invalid_member(self): ++ resources = { ++ "inner-group": '', ++ "inner-clone": '', ++ "inner-bundle": '', ++ } ++ ++ for resource_id, member in resources.items(): ++ with self.subTest(value=resource_id): ++ self.setUp() ++ group_xml = etree.fromstring( ++ fixture_group_xml( ++ resource_id="outer-group", members=[member] ++ ) ++ ) ++ ++ # pylint: disable=cell-var-from-loop ++ assert_raise_library_error( ++ lambda: status._group_to_dto( ++ self.report_processor, group_xml ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_UNEXPECTED_MEMBER, ++ resource_id="outer-group", ++ resource_type="group", ++ member_id=resource_id, ++ expected_types=["primitive"], ++ ) ++ ], ++ ) ++ ++ def test_remove_clone_suffix(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml( ++ resource_id="resource-group:0", ++ members=[fixture_primitive_xml(resource_id="resource:0")], ++ ) ++ ) ++ ++ result = status._group_to_dto(self.report_processor, group_xml, True) ++ self.assertEqual( ++ result, ++ fixture_group_dto(members=[fixture_primitive_dto()]), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ ++class TestCloneStatusToDto(TestCase): ++ # pylint: disable=protected-access ++ def setUp(self): ++ self.report_processor = MockLibraryReportProcessor() ++ ++ def test_all_attributes(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ description="Test description", ++ target_role=PCMK_STATUS_ROLE_STARTED, ++ ) ++ ) ++ ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_clone_dto( ++ description="Test description", target_role=PCMK_ROLE_STARTED ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_primitive_member(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml(instances=[fixture_primitive_xml()]) ++ ) ++ ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, fixture_clone_dto(instances=[fixture_primitive_dto()]) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_primitive_member_multiple(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[ ++ fixture_primitive_xml(), ++ fixture_primitive_xml(node_names=["node2"]), ++ ] ++ ) ++ ) ++ ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_clone_dto( ++ instances=[ ++ fixture_primitive_dto(), ++ fixture_primitive_dto(node_names=["node2"]), ++ ] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_primitive_member_unique(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ unique=True, ++ instances=[ ++ fixture_primitive_xml(resource_id="resource:0"), ++ fixture_primitive_xml( ++ resource_id="resource:1", node_names=["node2"] ++ ), ++ ], ++ ) ++ ) ++ ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_clone_dto( ++ unique=True, ++ instances=[ ++ fixture_primitive_dto(), ++ fixture_primitive_dto(node_names=["node2"]), ++ ], ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_primitive_member_promotable(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ multi_state=True, ++ instances=[ ++ fixture_primitive_xml(role=PCMK_STATUS_ROLE_UNPROMOTED), ++ fixture_primitive_xml( ++ role=PCMK_STATUS_ROLE_UNPROMOTED, node_names=["node2"] ++ ), ++ ], ++ ) ++ ) ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_clone_dto( ++ multi_state=True, ++ instances=[ ++ fixture_primitive_dto(role=PCMK_STATUS_ROLE_UNPROMOTED), ++ fixture_primitive_dto( ++ role=PCMK_STATUS_ROLE_UNPROMOTED, node_names=["node2"] ++ ), ++ ], ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_primitive_member_different_ids(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[ ++ fixture_primitive_xml(), ++ fixture_primitive_xml( ++ resource_id="not_the_same_id", node_names=["node2"] ++ ), ++ ] ++ ) ++ ) ++ ++ assert_raise_library_error( ++ lambda: status._clone_to_dto(self.report_processor, clone_xml) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS, ++ clone_id="resource-clone", ++ ) ++ ], ++ ) ++ ++ def test_group_member(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[ ++ fixture_group_xml( ++ resource_id="resource-group:0", ++ members=[fixture_primitive_xml()], ++ ), ++ fixture_group_xml( ++ resource_id="resource-group:1", ++ members=[fixture_primitive_xml(node_names=["node2"])], ++ ), ++ ], ++ ) ++ ) ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_clone_dto( ++ instances=[ ++ fixture_group_dto(members=[fixture_primitive_dto()]), ++ fixture_group_dto( ++ members=[fixture_primitive_dto(node_names=["node2"])] ++ ), ++ ], ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_group_member_unique(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ unique=True, ++ instances=[ ++ fixture_group_xml( ++ resource_id="resource-group:0", ++ members=[ ++ fixture_primitive_xml(resource_id="resource:0") ++ ], ++ ), ++ fixture_group_xml( ++ resource_id="resource-group:1", ++ members=[ ++ fixture_primitive_xml( ++ resource_id="resource:1", node_names=["node2"] ++ ) ++ ], ++ ), ++ ], ++ ) ++ ) ++ result = status._clone_to_dto(self.report_processor, clone_xml) ++ ++ self.assertEqual( ++ result, ++ fixture_clone_dto( ++ unique=True, ++ instances=[ ++ fixture_group_dto(members=[fixture_primitive_dto()]), ++ fixture_group_dto( ++ members=[fixture_primitive_dto(node_names=["node2"])] ++ ), ++ ], ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_group_member_different_group_ids(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[ ++ fixture_group_xml( ++ resource_id="resource-group:0", ++ members=[fixture_primitive_xml()], ++ ), ++ fixture_group_xml( ++ resource_id="another-id-:1", ++ members=[fixture_primitive_xml(node_names=["node2"])], ++ ), ++ ], ++ ) ++ ) ++ ++ assert_raise_library_error( ++ lambda: status._clone_to_dto(self.report_processor, clone_xml) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS, ++ clone_id="resource-clone", ++ ) ++ ], ++ ) ++ ++ def test_group_member_different_primitive_ids(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[ ++ fixture_group_xml( ++ resource_id="resource-group:0", ++ members=[fixture_primitive_xml()], ++ ), ++ fixture_group_xml( ++ resource_id="resource-group:1", ++ members=[ ++ fixture_primitive_xml( ++ resource_id="some-other-id", ++ node_names=["node2"], ++ ) ++ ], ++ ), ++ ], ++ ) ++ ) ++ ++ assert_raise_library_error( ++ lambda: status._clone_to_dto(self.report_processor, clone_xml) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS, ++ clone_id="resource-clone", ++ ) ++ ], ++ ) ++ ++ def test_primitive_member_types_mixed(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[ ++ fixture_group_xml( ++ resource_id="resource", ++ members=[ ++ fixture_primitive_xml(resource_id="inner-resource") ++ ], ++ ), ++ fixture_primitive_xml(node_names=["node2"]), ++ ], ++ ) ++ ) ++ ++ assert_raise_library_error( ++ lambda: status._clone_to_dto(self.report_processor, clone_xml) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_CLONE_MIXED_MEMBERS, ++ clone_id="resource-clone", ++ ) ++ ], ++ ) ++ ++ def test_invalid_member(self): ++ resources = { ++ "inner-clone": '', ++ "inner-bundle": '', ++ } ++ for resource_id, element in resources.items(): ++ with self.subTest(value=resource_id): ++ self.setUp() ++ clone_xml = etree.fromstring( ++ fixture_clone_xml(instances=[element]) ++ ) ++ ++ # pylint: disable=cell-var-from-loop ++ assert_raise_library_error( ++ lambda: status._clone_to_dto( ++ self.report_processor, clone_xml ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_UNEXPECTED_MEMBER, ++ resource_id="resource-clone", ++ resource_type="clone", ++ member_id=resource_id, ++ expected_types=["primitive", "group"], ++ ) ++ ], ++ ) ++ ++ ++class TestBundleReplicaStatusToDto(TestCase): ++ # pylint: disable=protected-access ++ def setUp(self): ++ self.report_processor = MockLibraryReportProcessor() ++ ++ def test_no_member_no_ip(self): ++ replica_xml = etree.fromstring(fixture_replica_xml()) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertEqual(result, fixture_replica_dto()) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_no_member(self): ++ replica_xml = etree.fromstring(fixture_replica_xml(ip=True)) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertEqual(result, fixture_replica_dto(ip=True)) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_member(self): ++ replica_xml = etree.fromstring( ++ fixture_replica_xml( ++ ip=True, ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-0"], ++ ), ++ ) ++ ) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertEqual( ++ result, ++ fixture_replica_dto( ++ ip=True, ++ member=fixture_primitive_dto(node_names=["resource-bundle-0"]), ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_member_no_ip(self): ++ replica_xml = etree.fromstring( ++ fixture_replica_xml( ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-0"], ++ ), ++ ) ++ ) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertEqual( ++ result, ++ fixture_replica_dto( ++ member=fixture_primitive_dto(node_names=["resource-bundle-0"]) ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_no_container(self): ++ replica_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ assert_raise_library_error( ++ lambda: status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER, ++ bundle_id=bundle_id, ++ replica_id="0", ++ ) ++ ], ++ ) ++ ++ def test_empty_replica(self): ++ replica_xml = etree.fromstring('') ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ assert_raise_library_error( ++ lambda: status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER, ++ bundle_id=bundle_id, ++ replica_id="0", ++ ) ++ ], ++ ) ++ ++ def test_member_no_remote(self): ++ replica_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ assert_raise_library_error( ++ lambda: status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE, ++ bundle_id=bundle_id, ++ replica_id="0", ++ ) ++ ], ++ ) ++ ++ def test_member_same_id_as_container(self): ++ # xml taken from crm_mon output ++ replica_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertTrue(result is None) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id=bundle_id, ++ bad_ids=["resource-bundle-podman-0"], ++ ) ++ ], ++ ) ++ ++ def test_member_same_id_as_remote(self): ++ # xml taken from crm_mon output ++ replica_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertTrue(result is None) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id=bundle_id, ++ bad_ids=["resource-bundle-0"], ++ ) ++ ], ++ ) ++ ++ def test_member_same_id_as_ip(self): ++ # xml taken from crm_mon output ++ replica_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ ++ result = status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ self.assertTrue(result is None) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id=bundle_id, ++ bad_ids=["resource-bundle-ip-192.168.122.250"], ++ ) ++ ], ++ ) ++ ++ def test_too_many_members(self): ++ replica_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ ++ bundle_id = "resource-bundle" ++ bundle_type = "podman" ++ assert_raise_library_error( ++ lambda: status._replica_to_dto( ++ self.report_processor, replica_xml, bundle_id, bundle_type ++ ) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT, ++ bundle_id=bundle_id, ++ replica_id="0", ++ ) ++ ], ++ ) ++ ++ ++class TestBundleStatusToDto(TestCase): ++ # pylint: disable=protected-access ++ def setUp(self): ++ self.report_processor = MockLibraryReportProcessor() ++ ++ def test_no_member(self): ++ bundle_xml = etree.fromstring( ++ fixture_bundle_xml(replicas=[fixture_replica_xml()]) ++ ) ++ ++ result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ self.assertEqual( ++ result, fixture_bundle_dto(replicas=[fixture_replica_dto()]) ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_member(self): ++ bundle_xml = etree.fromstring( ++ fixture_bundle_xml( ++ replicas=[ ++ fixture_replica_xml( ++ ip=True, ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ) ++ ) ++ result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ self.assertEqual( ++ result, ++ fixture_bundle_dto( ++ replicas=[ ++ fixture_replica_dto( ++ ip=True, ++ member=fixture_primitive_dto( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_multiple_replicas(self): ++ bundle_xml = etree.fromstring( ++ fixture_bundle_xml( ++ replicas=[ ++ fixture_replica_xml( ++ ip=True, ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-0"] ++ ), ++ ), ++ fixture_replica_xml( ++ ip=True, ++ replica_id="1", ++ node_name="node2", ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-1"] ++ ), ++ ), ++ ] ++ ) ++ ) ++ result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ self.assertEqual( ++ result, ++ fixture_bundle_dto( ++ replicas=[ ++ fixture_replica_dto( ++ ip=True, ++ member=fixture_primitive_dto( ++ node_names=["resource-bundle-0"] ++ ), ++ ), ++ fixture_replica_dto( ++ replica_id="1", ++ ip=True, ++ node_name="node2", ++ member=fixture_primitive_dto( ++ node_names=["resource-bundle-1"] ++ ), ++ ), ++ ] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_same_id_as_implicit(self): ++ bundle_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ self.assertTrue(result is None) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id="resource-bundle", ++ bad_ids=["resource-bundle-0"], ++ ) ++ ], ++ ) ++ ++ def test_same_id_as_implicit_multiple_replicas(self): ++ bundle_xml = etree.fromstring( ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """ ++ ) ++ result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ self.assertTrue(result is None) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id="resource-bundle", ++ bad_ids=["resource-bundle-1"], ++ ) ++ ], ++ ) ++ ++ def test_replicas_different(self): ++ replicas = { ++ "no-ip": fixture_replica_xml( ++ ip=False, member=fixture_primitive_xml() ++ ), ++ "different-member-id": fixture_replica_xml( ++ ip=True, member=fixture_primitive_xml(resource_id="another-id") ++ ), ++ "no-member": fixture_replica_xml(ip=True, member=None), ++ "different-member-agent": fixture_replica_xml( ++ ip=True, ++ member=fixture_primitive_xml( ++ resource_agent="ocf:heartbeat:apache" ++ ), ++ ), ++ } ++ for name, element in replicas.items(): ++ with self.subTest(value=name): ++ self.setUp() ++ ++ bundle_xml = etree.fromstring( ++ fixture_bundle_xml( ++ replicas=[ ++ element, ++ fixture_replica_xml( ++ ip=True, ++ replica_id="1", ++ member=fixture_primitive_xml(), ++ ), ++ ] ++ ) ++ ) ++ ++ # pylint: disable=cell-var-from-loop ++ assert_raise_library_error( ++ lambda: status._bundle_to_dto( ++ self.report_processor, bundle_xml ++ ) ++ ) ++ ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.error( ++ reports.codes.CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS, ++ bundle_id="resource-bundle", ++ ) ++ ], ++ ) ++ ++ ++class TestResourcesStatusToDto(TestCase): ++ def setUp(self): ++ self.report_processor = MockLibraryReportProcessor() ++ ++ def test_empty_resources(self): ++ status_xml = etree.fromstring(fixture_crm_mon_xml([])) ++ ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ self.assertEqual(result, ResourcesStatusDto([])) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_single_primitive(self): ++ status_xml = etree.fromstring( ++ fixture_crm_mon_xml([fixture_primitive_xml()]) ++ ) ++ ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ self.assertEqual(result, ResourcesStatusDto([fixture_primitive_dto()])) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_single_group(self): ++ status_xml = etree.fromstring( ++ fixture_crm_mon_xml( ++ [fixture_group_xml(members=[fixture_primitive_xml()])] ++ ) ++ ) ++ ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ self.assertEqual( ++ result, ++ ResourcesStatusDto( ++ [fixture_group_dto(members=[fixture_primitive_dto()])] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_single_clone(self): ++ status_xml = etree.fromstring( ++ fixture_crm_mon_xml( ++ [fixture_clone_xml(instances=[fixture_primitive_xml()])] ++ ) ++ ) ++ ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ self.assertEqual( ++ result, ++ ResourcesStatusDto( ++ [fixture_clone_dto(instances=[fixture_primitive_dto()])] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_single_bundle(self): ++ status_xml = etree.fromstring( ++ fixture_crm_mon_xml( ++ [ ++ fixture_bundle_xml( ++ replicas=[ ++ fixture_replica_xml( ++ ip=True, ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ) ++ ] ++ ) ++ ) ++ ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ self.assertEqual( ++ result, ++ ResourcesStatusDto( ++ [ ++ fixture_bundle_dto( ++ replicas=[ ++ fixture_replica_dto( ++ ip=True, ++ member=fixture_primitive_dto( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ) ++ ] ++ ), ++ ) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, [] ++ ) ++ ++ def test_all_resource_types(self): ++ status_xml = etree.fromstring( ++ fixture_crm_mon_xml( ++ [ ++ fixture_primitive_xml(), ++ fixture_group_xml(members=[fixture_primitive_xml()]), ++ fixture_clone_xml(instances=[fixture_primitive_xml()]), ++ fixture_bundle_xml( ++ replicas=[ ++ fixture_replica_xml( ++ ip=True, ++ member=fixture_primitive_xml( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ), ++ ] ++ ) ++ ) ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ ++ self.assertEqual(result.resources[0], fixture_primitive_dto()) ++ self.assertEqual( ++ result.resources[1], ++ fixture_group_dto(members=[fixture_primitive_dto()]), ++ ) ++ self.assertEqual( ++ result.resources[2], ++ fixture_clone_dto(instances=[fixture_primitive_dto()]), ++ ) ++ self.assertEqual( ++ result.resources[3], ++ fixture_bundle_dto( ++ replicas=[ ++ fixture_replica_dto( ++ ip=True, ++ member=fixture_primitive_dto( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ), ++ ) ++ ++ def test_skip_bundle(self): ++ status_xml = etree.fromstring( ++ fixture_crm_mon_xml( ++ [ ++ fixture_primitive_xml(), ++ """ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """, ++ ] ++ ) ++ ) ++ ++ result = status.status_xml_to_dto(self.report_processor, status_xml) ++ ++ self.assertEqual(result, ResourcesStatusDto([fixture_primitive_dto()])) ++ assert_report_item_list_equal( ++ self.report_processor.report_item_list, ++ [ ++ fixture.warn( ++ reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id="resource-bundle", ++ bad_ids=["resource-bundle-0"], ++ ) ++ ], ++ ) +-- +2.25.1 + diff --git a/backport-fix-booth-destroy-for-arbitrators.patch b/backport-fix-booth-destroy-for-arbitrators.patch new file mode 100644 index 0000000..eabd514 --- /dev/null +++ b/backport-fix-booth-destroy-for-arbitrators.patch @@ -0,0 +1,243 @@ +From af9510afb3ce53b3dd05136fdbb9f0a5cc048205 Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Fri, 31 May 2024 16:00:06 +0200 +Subject: [PATCH] fix booth destroy for arbitrators + +--- + pcs/lib/commands/booth.py | 35 ++++--- + pcs/lib/pacemaker/live.py | 4 + + pcs_test/tier0/lib/commands/test_booth.py | 110 ++++++++++++++++++++-- + 3 files changed, 129 insertions(+), 20 deletions(-) + +diff --git a/pcs/lib/commands/booth.py b/pcs/lib/commands/booth.py +index c961705b..f291a085 100644 +--- a/pcs/lib/commands/booth.py ++++ b/pcs/lib/commands/booth.py +@@ -58,6 +58,7 @@ from pcs.lib.file.raw_file import ( + ) + from pcs.lib.interface.config import ParserErrorException + from pcs.lib.node import get_existing_nodes_names ++from pcs.lib.pacemaker.live import has_cib_xml + from pcs.lib.resource_agent import ( + ResourceAgentError, + ResourceAgentFacade, +@@ -165,20 +166,30 @@ def config_destroy( + found_instance_name = booth_env.instance_name + _ensure_live_env(env, booth_env) + +- booth_resource_list = resource.find_for_config( +- get_resources(env.get_cib()), +- booth_env.config_path, +- ) +- if booth_resource_list: +- report_processor.report( +- ReportItem.error( +- reports.messages.BoothConfigIsUsed( +- found_instance_name, +- reports.const.BOOTH_CONFIG_USED_IN_CLUSTER_RESOURCE, +- resource_name=str(booth_resource_list[0].get("id", "")), ++ if ( ++ has_cib_xml() ++ or env.service_manager.is_running("pacemaker") ++ or env.service_manager.is_running("pacemaker_remoted") ++ ): ++ # To allow destroying booth config on arbitrators, only check CIB if: ++ # * pacemaker is running and therefore we are able to get CIB ++ # * CIB is stored on disk - pcmk is not running but the node is in a ++ # cluster (don't checking corosync to cover remote and guest nodes) ++ # If CIB cannot be loaded in either case, fail with an error. ++ booth_resource_list = resource.find_for_config( ++ get_resources(env.get_cib()), ++ booth_env.config_path, ++ ) ++ if booth_resource_list: ++ report_processor.report( ++ ReportItem.error( ++ reports.messages.BoothConfigIsUsed( ++ found_instance_name, ++ reports.const.BOOTH_CONFIG_USED_IN_CLUSTER_RESOURCE, ++ resource_name=str(booth_resource_list[0].get("id", "")), ++ ) + ) + ) +- ) + # Only systemd is currently supported. Initd does not supports multiple + # instances (here specified by name) + if is_systemd(env.service_manager): +diff --git a/pcs/lib/pacemaker/live.py b/pcs/lib/pacemaker/live.py +index 301ce343..43197ac1 100644 +--- a/pcs/lib/pacemaker/live.py ++++ b/pcs/lib/pacemaker/live.py +@@ -151,6 +151,10 @@ def get_ticket_status_text(runner: CommandRunner) -> Tuple[str, str, int]: + ### cib + + ++def has_cib_xml() -> bool: ++ return os.path.exists(os.path.join(settings.cib_dir, "cib.xml")) ++ ++ + def get_cib_xml_cmd_results( + runner: CommandRunner, scope: Optional[str] = None + ) -> tuple[str, str, int]: +diff --git a/pcs_test/tier0/lib/commands/test_booth.py b/pcs_test/tier0/lib/commands/test_booth.py +index 4e945216..2957e378 100644 +--- a/pcs_test/tier0/lib/commands/test_booth.py ++++ b/pcs_test/tier0/lib/commands/test_booth.py +@@ -524,10 +524,13 @@ class ConfigSetupAuthfileFix(TestCase, FixtureMixin): + + + class ConfigDestroy(TestCase, FixtureMixin): ++ # pylint: disable=too-many-public-methods + def setUp(self): + self.env_assist, self.config = get_env_tools(self) ++ self.cib_path = os.path.join(settings.cib_dir, "cib.xml") + + def fixture_config_booth_not_used(self, instance_name="booth"): ++ self.config.fs.exists(self.cib_path, True) + self.config.runner.cib.load() + self.config.services.is_running( + "booth", instance=instance_name, return_value=False +@@ -536,6 +539,44 @@ class ConfigDestroy(TestCase, FixtureMixin): + "booth", instance=instance_name, return_value=False + ) + ++ def fixture_config_booth_used( ++ self, ++ instance_name, ++ cib_exists=False, ++ pcmk_running=False, ++ pcmk_remote_running=False, ++ booth_running=False, ++ booth_enabled=False, ++ ): ++ cib_load_exception = False ++ self.config.fs.exists(self.cib_path, cib_exists) ++ if not cib_exists: ++ self.config.services.is_running( ++ "pacemaker", ++ return_value=pcmk_running, ++ name="services.is_running.pcmk", ++ ) ++ if not pcmk_running: ++ self.config.services.is_running( ++ "pacemaker_remoted", ++ return_value=pcmk_remote_running, ++ name="services.is_running.pcmk_remote", ++ ) ++ if cib_exists and not pcmk_running and not pcmk_remote_running: ++ self.config.runner.cib.load( ++ returncode=1, stderr="unable to get cib, pcmk is not running" ++ ) ++ cib_load_exception = True ++ elif pcmk_running or pcmk_remote_running: ++ self.config.runner.cib.load(resources=self.fixture_cib_resources()) ++ if not cib_load_exception: ++ self.config.services.is_running( ++ "booth", instance=instance_name, return_value=booth_running ++ ) ++ self.config.services.is_enabled( ++ "booth", instance=instance_name, return_value=booth_enabled ++ ) ++ + def fixture_config_success(self, instance_name="booth"): + self.fixture_config_booth_not_used(instance_name) + self.config.raw_file.read( +@@ -663,17 +704,29 @@ class ConfigDestroy(TestCase, FixtureMixin): + expected_in_processor=False, + ) + +- def test_booth_config_in_use(self): ++ def test_booth_config_in_use_cib_pcmk(self): + instance_name = "booth" ++ self.fixture_config_booth_used(instance_name, pcmk_running=True) + +- self.config.runner.cib.load(resources=self.fixture_cib_resources()) +- self.config.services.is_running( +- "booth", instance=instance_name, return_value=True ++ self.env_assist.assert_raise_library_error( ++ lambda: commands.config_destroy(self.env_assist.get_env()), + ) +- self.config.services.is_enabled( +- "booth", instance=instance_name, return_value=True ++ ++ self.env_assist.assert_reports( ++ [ ++ fixture.error( ++ reports.codes.BOOTH_CONFIG_IS_USED, ++ name=instance_name, ++ detail=reports.const.BOOTH_CONFIG_USED_IN_CLUSTER_RESOURCE, ++ resource_name="booth_resource", ++ ), ++ ] + ) + ++ def test_booth_config_in_use_cib_pcmk_remote(self): ++ instance_name = "booth" ++ self.fixture_config_booth_used(instance_name, pcmk_remote_running=True) ++ + self.env_assist.assert_raise_library_error( + lambda: commands.config_destroy(self.env_assist.get_env()), + ) +@@ -686,16 +739,57 @@ class ConfigDestroy(TestCase, FixtureMixin): + detail=reports.const.BOOTH_CONFIG_USED_IN_CLUSTER_RESOURCE, + resource_name="booth_resource", + ), ++ ] ++ ) ++ ++ def test_pcmk_not_running(self): ++ instance_name = "booth" ++ self.fixture_config_booth_used(instance_name, cib_exists=True) ++ ++ self.env_assist.assert_raise_library_error( ++ lambda: commands.config_destroy(self.env_assist.get_env()), ++ [ ++ fixture.error( ++ reports.codes.CIB_LOAD_ERROR, ++ reason="unable to get cib, pcmk is not running", ++ ) ++ ], ++ expected_in_processor=False, ++ ) ++ ++ def test_booth_config_in_use_systemd_running(self): ++ instance_name = "booth" ++ self.fixture_config_booth_used(instance_name, booth_running=True) ++ ++ self.env_assist.assert_raise_library_error( ++ lambda: commands.config_destroy(self.env_assist.get_env()), ++ ) ++ ++ self.env_assist.assert_reports( ++ [ + fixture.error( + reports.codes.BOOTH_CONFIG_IS_USED, + name=instance_name, +- detail=reports.const.BOOTH_CONFIG_USED_ENABLED_IN_SYSTEMD, ++ detail=reports.const.BOOTH_CONFIG_USED_RUNNING_IN_SYSTEMD, + resource_name=None, + ), ++ ] ++ ) ++ ++ def test_booth_config_in_use_systemd_enabled(self): ++ instance_name = "booth" ++ self.fixture_config_booth_used(instance_name, booth_enabled=True) ++ ++ self.env_assist.assert_raise_library_error( ++ lambda: commands.config_destroy(self.env_assist.get_env()), ++ ) ++ ++ self.env_assist.assert_reports( ++ [ + fixture.error( + reports.codes.BOOTH_CONFIG_IS_USED, + name=instance_name, +- detail=reports.const.BOOTH_CONFIG_USED_RUNNING_IN_SYSTEMD, ++ detail=reports.const.BOOTH_CONFIG_USED_ENABLED_IN_SYSTEMD, + resource_name=None, + ), + ] +-- +2.25.1 + diff --git a/backport-fix-stdout-wrapping-to-terminal-width.patch b/backport-fix-stdout-wrapping-to-terminal-width.patch new file mode 100644 index 0000000..fcd7fa2 --- /dev/null +++ b/backport-fix-stdout-wrapping-to-terminal-width.patch @@ -0,0 +1,49 @@ +From 2f4ebe9dfb2d9854e6ae05834e6062d245dae88d Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Thu, 16 May 2024 10:36:23 +0200 +Subject: [PATCH] fix stdout wrapping to terminal width + +--- + CHANGELOG.md | 3 +++ + pcs/cli/common/output.py | 8 +++++--- + 2 files changed, 8 insertions(+), 3 deletions(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index a198d0f7..a6ef6cc2 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -7,9 +7,12 @@ + when not specified in `pcs cluster uidgid add` command. Empty options cause + corosync start failure. ([ghissue#772]) + - Do not allow fencing levels other than 1..9 ([RHEL-2977]) ++- Do not wrap pcs output to terminal width if pcs's stdout is redirected ++ ([RHEL-36514]) + + [ghissue#772]: https://github.com/ClusterLabs/pcs/issues/772 + [RHEL-2977]: https://issues.redhat.com/browse/RHEL-2977 ++[RHEL-36514]: https://issues.redhat.com/browse/RHEL-36514 + + + ## [0.11.7] - 2024-01-11 +diff --git a/pcs/cli/common/output.py b/pcs/cli/common/output.py +index 179f7c03..9dc0e162 100644 +--- a/pcs/cli/common/output.py ++++ b/pcs/cli/common/output.py +@@ -56,9 +56,11 @@ def format_wrap_for_terminal( + trim -- number which will be substracted from terminal size. Can be used in + cases lines will be indented later by this number of spaces. + """ +- if (sys.stdout is not None and sys.stdout.isatty()) or ( +- sys.stderr is not None and sys.stderr.isatty() +- ): ++ # This function is used for stdout only - we don't care about wrapping ++ # error messages and debug info. So it checks stdout and not stderr. ++ # Checking stderr would enable wrapping in case of 'pcs ... | grep ...' ++ # (stderr is connected to a terminal), which we don't want. (RHEL-36514) ++ if sys.stdout is not None and sys.stdout.isatty(): + return format_wrap( + text, + # minimal line length is 40 +-- +2.25.1 + diff --git a/fixes-after-review.patch b/fixes-after-review.patch new file mode 100644 index 0000000..0f1f4c2 --- /dev/null +++ b/fixes-after-review.patch @@ -0,0 +1,3129 @@ +From 75a8e52584a71087ae734c427870c9ea8d7935c7 Mon Sep 17 00:00:00 2001 +From: Peter Romancik +Date: Tue, 30 Jan 2024 18:02:50 +0100 +Subject: [PATCH] fixes after review + +--- + pcs/common/const.py | 2 - + pcs/common/reports/codes.py | 20 +- + pcs/common/reports/messages.py | 187 +--- + pcs/lib/commands/status.py | 16 +- + pcs/lib/pacemaker/status.py | 527 ++++++----- + pcs_test/resources/crm_mon.all_resources.xml | 75 +- + .../tier0/common/reports/test_messages.py | 155 +--- + pcs_test/tier0/lib/commands/test_status.py | 303 ++++-- + pcs_test/tier0/lib/pacemaker/test_status.py | 865 ++++++++---------- + 9 files changed, 968 insertions(+), 1182 deletions(-) + +diff --git a/pcs/common/const.py b/pcs/common/const.py +index 32175677..00d1b7e7 100644 +--- a/pcs/common/const.py ++++ b/pcs/common/const.py +@@ -14,14 +14,12 @@ PCMK_ROLE_PROMOTED = PcmkRoleType("Promoted") + PCMK_ROLE_UNPROMOTED = PcmkRoleType("Unpromoted") + PCMK_ROLE_PROMOTED_LEGACY = PcmkRoleType("Master") + PCMK_ROLE_UNPROMOTED_LEGACY = PcmkRoleType("Slave") +-PCMK_ROLE_UNKNOWN = PcmkRoleType("Unknown") + PCMK_STATUS_ROLE_STARTED = PcmkStatusRoleType("Started") + PCMK_STATUS_ROLE_STOPPED = PcmkStatusRoleType("Stopped") + PCMK_STATUS_ROLE_PROMOTED = PcmkStatusRoleType("Promoted") + PCMK_STATUS_ROLE_UNPROMOTED = PcmkStatusRoleType("Unpromoted") + PCMK_STATUS_ROLE_STARTING = PcmkStatusRoleType("Starting") + PCMK_STATUS_ROLE_STOPPING = PcmkStatusRoleType("Stopping") +-PCMK_STATUS_ROLE_UNKNOWN = PcmkStatusRoleType("Unknown") + PCMK_ON_FAIL_ACTION_IGNORE = PcmkOnFailAction("ignore") + PCMK_ON_FAIL_ACTION_BLOCK = PcmkOnFailAction("block") + PCMK_ON_FAIL_ACTION_DEMOTE = PcmkOnFailAction("demote") +diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py +index 417a3f4a..f9614331 100644 +--- a/pcs/common/reports/codes.py ++++ b/pcs/common/reports/codes.py +@@ -50,6 +50,7 @@ AGENT_SELF_VALIDATION_SKIPPED_UPDATED_RESOURCE_MISCONFIGURED = M( + ) + AGENT_SELF_VALIDATION_RESULT = M("AGENT_SELF_VALIDATION_RESULT") + BAD_CLUSTER_STATE_FORMAT = M("BAD_CLUSTER_STATE_FORMAT") ++BAD_CLUSTER_STATE = M("BAD_CLUSTER_STATE") + BOOTH_ADDRESS_DUPLICATION = M("BOOTH_ADDRESS_DUPLICATION") + BOOTH_ALREADY_IN_CIB = M("BOOTH_ALREADY_IN_CIB") + BOOTH_AUTHFILE_NOT_USED = M("BOOTH_AUTHFILE_NOT_USED") +@@ -156,28 +157,9 @@ CLUSTER_RESTART_REQUIRED_TO_APPLY_CHANGES = M( + CLUSTER_SETUP_SUCCESS = M("CLUSTER_SETUP_SUCCESS") + CLUSTER_START_STARTED = M("CLUSTER_START_STARTED") + CLUSTER_START_SUCCESS = M("CLUSTER_START_SUCCESS") +-CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS = M( +- "CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS" +-) + CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT = M( + "CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT" + ) +-CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT = M( +- "CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT" +-) +-CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE = M( +- "CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE" +-) +-CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER = M( +- "CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER" +-) +-CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS = M( +- "CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS" +-) +-CLUSTER_STATUS_CLONE_MIXED_MEMBERS = M("CLUSTER_STATUS_CLONE_MIXED_MEMBERS") +-CLUSTER_STATUS_EMPTY_NODE_NAME = M("CLUSTER_STATUS_EMPTY_NODE_NAME") +-CLUSTER_STATUS_UNEXPECTED_MEMBER = M("CLUSTER_STATUS_UNEXPECTED_MEMBER") +-CLUSTER_STATUS_UNKNOWN_PCMK_ROLE = M("CLUSTER_STATUS_UNKNOWN_PCMK_ROLE") + CLUSTER_UUID_ALREADY_SET = M("CLUSTER_UUID_ALREADY_SET") + CLUSTER_WILL_BE_DESTROYED = M("CLUSTER_WILL_BE_DESTROYED") + COMMAND_INVALID_PAYLOAD = M("COMMAND_INVALID_PAYLOAD") +diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py +index 1e98711c..8b9bc63e 100644 +--- a/pcs/common/reports/messages.py ++++ b/pcs/common/reports/messages.py +@@ -3277,183 +3277,32 @@ class BadClusterStateFormat(ReportItemMessage): + + + @dataclass(frozen=True) +-class ClusterStatusUnknownPcmkRole(ReportItemMessage): ++class BadClusterState(ReportItemMessage): + """ +- Value of pcmk role in the status xml is not valid ++ crm_mon xml output is invalid despite conforming to the schema + +- role -- value of the role attribute +- resource_id -- id of the resource +- """ +- +- role: Optional[str] +- resource_id: str +- _code = codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE +- +- @property +- def message(self) -> str: +- return ( +- "Attribute of resource with id '{id}' " +- "contains {invalid} pcmk role{role}." +- ).format( +- id=self.resource_id, +- invalid="empty" if not self.role else "invalid", +- role=f" '{self.role}'" if self.role else "", +- ) +- +- +-@dataclass(frozen=True) +-class ClusterStatusEmptyNodeName(ReportItemMessage): +- """ +- Resource in the status xml contains node with empty name +- +- resource_id -- id of the resource +- """ +- +- resource_id: str +- _code = codes.CLUSTER_STATUS_EMPTY_NODE_NAME +- +- @property +- def message(self) -> str: +- return ( +- f"Resource with id '{self.resource_id}' contains node " +- "with empty name." +- ) +- +- +-@dataclass(frozen=True) +-class ClusterStatusUnexpectedMember(ReportItemMessage): +- """ +- Unexpected resource type is present in present as child element +- in another resource type +- +- resource_id -- id of the outer resource +- resource_type -- type of the outer resource +- member_id -- id of the unexpected member +- expected_type -- valid types for members +- """ +- +- resource_id: str +- resource_type: str +- member_id: str +- expected_types: list[str] +- _code = codes.CLUSTER_STATUS_UNEXPECTED_MEMBER +- +- @property +- def message(self) -> str: +- return ( +- f"Unexpected resource '{self.member_id}' inside of resource " +- f"'{self.resource_id}' of type '{self.resource_type}'. " +- f"Only resources of type {format_list(self.expected_types, '|')} " +- f"can be in {self.resource_type}." +- ) +- +- +-@dataclass(frozen=True) +-class ClusterStatusCloneMixedMembers(ReportItemMessage): +- """ +- Members of multiple types are present in a clone in the status xml +- +- member_id -- id of the unexpected member +- clone_id -- id of the clone +- """ +- +- clone_id: str +- _code = codes.CLUSTER_STATUS_CLONE_MIXED_MEMBERS +- +- @property +- def message(self) -> str: +- return f"Primitive and group members mixed in clone '{self.clone_id}'." +- +- +-@dataclass(frozen=True) +-class ClusterStatusCloneMembersDifferentIds(ReportItemMessage): +- """ +- Clone instances in crm_mon status xml have different ids +- +- clone_id -- id of the clone +- """ +- +- clone_id: str +- _code = codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS +- +- @property +- def message(self) -> str: +- return f"Members with different ids in clone '{self.clone_id}'." +- +- +-@dataclass(frozen=True) +-class ClusterStatusBundleReplicaNoContainer(ReportItemMessage): +- """ +- Bundle replica is missing implicit container resource in the status xml +- +- bundle_id -- id of the bundle +- replica_id -- id of the replica +- """ +- +- bundle_id: str +- replica_id: str +- _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER +- +- @property +- def message(self) -> str: +- return ( +- f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' " +- "is missing implicit container resource." +- ) +- +- +-@dataclass(frozen=True) +-class ClusterStatusBundleReplicaMissingRemote(ReportItemMessage): +- """ +- Bundle replica is missing implicit pacemaker remote resource +- in the status xml +- +- bundle_id -- id of the bundle +- replica_id -- id of the replica +- """ +- +- bundle_id: str +- replica_id: str +- _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE +- +- @property +- def message(self) -> str: +- return ( +- f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' is " +- "missing implicit pacemaker remote resource while it must be " +- "present." +- ) +- +- +-@dataclass(frozen=True) +-class ClusterStatusBundleReplicaInvalidCount(ReportItemMessage): ++ reason -- error description + """ +- Bundle replica is has invalid number of members in the status xml + +- bundle_id -- id of the bundle +- replica_id -- id of the replica +- """ +- +- bundle_id: str +- replica_id: str +- _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT ++ reason: Optional[str] = None ++ _code = codes.BAD_CLUSTER_STATE + + @property + def message(self) -> str: + return ( +- f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' has " +- f"invalid number of members. Expecting 2-4 members." ++ "Cannot load cluster status, xml does not describe valid cluster " ++ f"status{format_optional(self.reason, template=': {}')}." + ) + + + @dataclass(frozen=True) + class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage): + """ +- Member of bundle in cluster status xml has the same id as one of +- the implicit resources ++ Member of bundle in cluster status xml has the same id as one of the ++ implicit resources + + bundle_id -- id of the bundle +- member_id -- id if the bundle member ++ bad_ids -- ids of the bad members + """ + + bundle_id: str +@@ -3474,22 +3323,6 @@ class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage): + ) + + +-@dataclass(frozen=True) +-class ClusterStatusBundleDifferentReplicas(ReportItemMessage): +- """ +- Replicas of bundle are different in the cluster status xml +- +- bundle_id -- id of the bundle +- """ +- +- bundle_id: str +- _code = codes.CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS +- +- @property +- def message(self) -> str: +- return f"Replicas of bundle '{self.bundle_id}' are not the same." +- +- + @dataclass(frozen=True) + class WaitForIdleStarted(ReportItemMessage): + """ +diff --git a/pcs/lib/commands/status.py b/pcs/lib/commands/status.py +index 8b644ac1..f9ec6160 100644 +--- a/pcs/lib/commands/status.py ++++ b/pcs/lib/commands/status.py +@@ -49,7 +49,11 @@ from pcs.lib.pacemaker.live import ( + get_cluster_status_xml_raw, + get_ticket_status_text, + ) +-from pcs.lib.pacemaker.status import status_xml_to_dto ++from pcs.lib.pacemaker.status import ( ++ ClusterStatusParser, ++ ClusterStatusParsingError, ++ cluster_status_parsing_error_to_report, ++) + from pcs.lib.resource_agent.const import STONITH_ACTION_REPLACED_BY + from pcs.lib.sbd import get_sbd_service_name + +@@ -79,7 +83,15 @@ def resources_status(env: LibraryEnvironment) -> ResourcesStatusDto: + """ + status_xml = env.get_cluster_state() + +- return status_xml_to_dto(env.report_processor, status_xml) ++ parser = ClusterStatusParser(status_xml) ++ try: ++ dto = parser.status_xml_to_dto() ++ except ClusterStatusParsingError as e: ++ raise LibraryError(cluster_status_parsing_error_to_report(e)) from e ++ ++ env.report_processor.report_list(parser.get_warnings()) ++ ++ return dto + + + def full_cluster_status_plaintext( +diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py +index 722ce03f..a86ede55 100644 +--- a/pcs/lib/pacemaker/status.py ++++ b/pcs/lib/pacemaker/status.py +@@ -1,3 +1,4 @@ ++from collections import Counter + from typing import ( + Optional, + Sequence, +@@ -9,14 +10,11 @@ from lxml.etree import _Element + + from pcs.common import reports + from pcs.common.const import ( +- PCMK_ROLE_UNKNOWN, + PCMK_ROLES, +- PCMK_STATUS_ROLE_UNKNOWN, + PCMK_STATUS_ROLES, + PcmkRoleType, + PcmkStatusRoleType, + ) +-from pcs.common.reports import ReportProcessor + from pcs.common.status_dto import ( + AnyResourceStatusDto, + BundleReplicaStatusDto, +@@ -26,7 +24,7 @@ from pcs.common.status_dto import ( + PrimitiveStatusDto, + ResourcesStatusDto, + ) +-from pcs.lib.errors import LibraryError ++from pcs.common.str_tools import format_list + from pcs.lib.pacemaker.values import is_true + + _PRIMITIVE_TAG = "resource" +@@ -36,31 +34,137 @@ _BUNDLE_TAG = "bundle" + _REPLICA_TAG = "replica" + + ++class ClusterStatusParsingError(Exception): ++ def __init__(self, resource_id: str): ++ self.resource_id = resource_id ++ ++ ++class EmptyResourceIdError(ClusterStatusParsingError): ++ def __init__(self): ++ super().__init__("") ++ ++ ++class EmptyNodeNameError(ClusterStatusParsingError): ++ pass ++ ++ ++class UnknownPcmkRoleError(ClusterStatusParsingError): ++ def __init__(self, resource_id: str, role: str): ++ super().__init__(resource_id) ++ self.role = role ++ ++ ++class UnexpectedMemberError(ClusterStatusParsingError): ++ def __init__( ++ self, ++ resource_id: str, ++ resource_type: str, ++ member_id: str, ++ expected_types: list[str], ++ ): ++ super().__init__(resource_id) ++ self.resource_type = resource_type ++ self.member_id = member_id ++ self.expected_types = expected_types ++ ++ ++class MixedMembersError(ClusterStatusParsingError): ++ pass ++ ++ ++class DifferentMemberIdsError(ClusterStatusParsingError): ++ pass ++ ++ ++class BundleReplicaMissingImplicitResourceError(ClusterStatusParsingError): ++ def __init__( ++ self, resource_id: str, replica_id: str, implicit_resource_type: str ++ ): ++ super().__init__(resource_id) ++ self.replica_id = replica_id ++ self.implicit_type = implicit_resource_type ++ ++ ++class BundleReplicaInvalidMemberCountError(ClusterStatusParsingError): ++ def __init__(self, resource_id: str, replica_id: str): ++ super().__init__(resource_id) ++ self.replica_id = replica_id ++ ++ ++class BundleDifferentReplicas(ClusterStatusParsingError): ++ pass ++ ++ ++class BundleSameIdAsImplicitResourceError(Exception): ++ def __init__(self, bundle_id: str, bad_ids: list[str]): ++ self.bundle_id = bundle_id ++ self.bad_ids = bad_ids ++ ++ ++def cluster_status_parsing_error_to_report( ++ e: ClusterStatusParsingError, ++) -> reports.ReportItem: ++ reason = "" ++ if isinstance(e, EmptyResourceIdError): ++ reason = "Resource with empty id." ++ elif isinstance(e, EmptyNodeNameError): ++ reason = ( ++ f"Resource with id '{e.resource_id}' contains node with empty name." ++ ) ++ elif isinstance(e, UnknownPcmkRoleError): ++ reason = ( ++ f"Resource with id '{e.resource_id}' contains unknown " ++ f"pcmk role '{e.role}'." ++ ) ++ elif isinstance(e, UnexpectedMemberError): ++ reason = ( ++ f"Unexpected resource '{e.member_id}' inside of resource " ++ f"'{e.resource_id}' of type '{e.resource_type}'. " ++ f"Only resources of type {format_list(e.expected_types, '|')} " ++ f"can be in {e.resource_type}." ++ ) ++ ++ elif isinstance(e, MixedMembersError): ++ reason = ( ++ f"Primitive and group members mixed in clone '{e.resource_id}'." ++ ) ++ elif isinstance(e, DifferentMemberIdsError): ++ reason = f"Members with different ids in resource '{e.resource_id}'." ++ elif isinstance(e, BundleReplicaMissingImplicitResourceError): ++ reason = ( ++ f"Replica '{e.replica_id}' of bundle '{e.resource_id}' " ++ f"is missing implicit {e.implicit_type} resource." ++ ) ++ elif isinstance(e, BundleReplicaInvalidMemberCountError): ++ reason = ( ++ f"Replica '{e.replica_id}' of bundle '{e.resource_id}' has " ++ "invalid number of members." ++ ) ++ elif isinstance(e, BundleDifferentReplicas): ++ reason = f"Replicas of bundle '{e.resource_id}' are not the same." ++ ++ return reports.ReportItem( ++ reports.ReportItemSeverity.error(), ++ reports.messages.BadClusterState(reason), ++ ) ++ ++ + def _primitive_to_dto( +- reporter: ReportProcessor, +- primitive_el: _Element, +- remove_clone_suffix: bool = False, ++ primitive_el: _Element, remove_clone_suffix: bool = False + ) -> PrimitiveStatusDto: +- resource_id = _get_resource_id(reporter, primitive_el) ++ resource_id = _get_resource_id(primitive_el) + if remove_clone_suffix: + resource_id = _remove_clone_suffix(resource_id) + +- role = _get_role(reporter, primitive_el, resource_id) +- target_role = _get_target_role(reporter, primitive_el, resource_id) ++ role = _get_role(primitive_el) ++ target_role = _get_target_role(primitive_el) + + node_names = [ + str(node.get("name")) for node in primitive_el.iterfind("node") + ] + + if node_names and any(not name for name in node_names): +- reporter.report( +- reports.ReportItem.error( +- reports.messages.ClusterStatusEmptyNodeName(resource_id) +- ) +- ) +- +- if reporter.has_errors: +- raise LibraryError() ++ raise EmptyNodeNameError(resource_id) + + return PrimitiveStatusDto( + resource_id, +@@ -82,87 +186,70 @@ def _primitive_to_dto( + + + def _group_to_dto( +- reporter: ReportProcessor, +- group_el: _Element, +- remove_clone_suffix: bool = False, ++ group_el: _Element, remove_clone_suffix: bool = False + ) -> GroupStatusDto: + # clone suffix is added even when the clone is non unique +- group_id = _remove_clone_suffix(_get_resource_id(reporter, group_el)) +- members = [] ++ group_id = _remove_clone_suffix(_get_resource_id(group_el)) ++ member_list = [] + + for member in group_el: + if member.tag == _PRIMITIVE_TAG: +- members.append( +- _primitive_to_dto(reporter, member, remove_clone_suffix) +- ) ++ member_list.append(_primitive_to_dto(member, remove_clone_suffix)) + else: +- reporter.report( +- reports.ReportItem.error( +- reports.messages.ClusterStatusUnexpectedMember( +- group_id, "group", str(member.get("id")), ["primitive"] +- ) +- ) ++ raise UnexpectedMemberError( ++ group_id, "group", str(member.get("id")), ["primitive"] + ) + +- if reporter.has_errors: +- raise LibraryError() +- + return GroupStatusDto( + group_id, + is_true(group_el.get("maintenance", "false")), + group_el.get("description"), + is_true(group_el.get("managed", "false")), + is_true(group_el.get("disabled", "false")), +- members, ++ member_list, + ) + + + def _clone_to_dto( +- reporter: ReportProcessor, +- clone_el: _Element, +- _remove_clone_suffix: bool = False, ++ clone_el: _Element, _remove_clone_suffix: bool = False + ) -> CloneStatusDto: +- clone_id = _get_resource_id(reporter, clone_el) ++ clone_id = _get_resource_id(clone_el) + is_unique = is_true(clone_el.get("unique", "false")) + +- target_role = _get_target_role(reporter, clone_el, clone_id) ++ target_role = _get_target_role(clone_el) + +- primitives = [] +- groups = [] ++ primitive_list = [] ++ group_list = [] + + for member in clone_el: + if member.tag == _PRIMITIVE_TAG: +- primitives.append(_primitive_to_dto(reporter, member, is_unique)) ++ primitive_list.append(_primitive_to_dto(member, is_unique)) + elif member.tag == _GROUP_TAG: +- groups.append(_group_to_dto(reporter, member, is_unique)) ++ group_list.append(_group_to_dto(member, is_unique)) + else: +- reporter.report( +- reports.ReportItem.error( +- reports.messages.ClusterStatusUnexpectedMember( +- clone_id, +- "clone", +- str(member.get("id")), +- ["primitive", "group"], +- ) +- ) ++ raise UnexpectedMemberError( ++ clone_id, "clone", str(member.get("id")), ["primitive", "group"] + ) + +- reporter.report_list( +- _validate_mixed_instance_types(primitives, groups, clone_id) +- ) ++ if primitive_list and group_list: ++ raise MixedMembersError(clone_id) + +- instances: Union[list[PrimitiveStatusDto], list[GroupStatusDto]] +- if primitives: +- reporter.report_list( +- _validate_primitive_instance_ids(primitives, clone_id) +- ) +- instances = primitives ++ instance_list: Union[list[PrimitiveStatusDto], list[GroupStatusDto]] ++ if primitive_list: ++ if len(set(res.resource_id for res in primitive_list)) > 1: ++ raise DifferentMemberIdsError(clone_id) ++ instance_list = primitive_list + else: +- reporter.report_list(_validate_group_instance_ids(groups, clone_id)) +- instances = groups ++ group_ids = set(group.resource_id for group in group_list) ++ children_ids = set( ++ tuple(child.resource_id for child in group.members) ++ for group in group_list ++ ) ++ ++ if len(group_ids) > 1 or len(children_ids) > 1: ++ raise DifferentMemberIdsError(clone_id) + +- if reporter.has_errors: +- raise LibraryError() ++ instance_list = group_list + + return CloneStatusDto( + clone_id, +@@ -175,30 +262,23 @@ def _clone_to_dto( + is_true(clone_el.get("failed", "false")), + is_true(clone_el.get("failure_ignored", "false")), + target_role, +- instances, ++ instance_list, + ) + + + def _bundle_to_dto( +- reporter: ReportProcessor, +- bundle_el: _Element, +- _remove_clone_suffix: bool = False, +-) -> Optional[BundleStatusDto]: +- bundle_id = _get_resource_id(reporter, bundle_el) ++ bundle_el: _Element, _remove_clone_suffix: bool = False ++) -> BundleStatusDto: ++ bundle_id = _get_resource_id(bundle_el) + bundle_type = str(bundle_el.get("type")) + +- replicas = [] +- for replica in bundle_el.iterfind(_REPLICA_TAG): +- replica_dto = _replica_to_dto(reporter, replica, bundle_id, bundle_type) +- if replica_dto is None: +- # skip this bundle in status +- return None +- replicas.append(replica_dto) +- +- reporter.report_list(_validate_replicas(replicas, bundle_id)) ++ replica_list = [ ++ _replica_to_dto(replica, bundle_id, bundle_type) ++ for replica in bundle_el.iterfind(_REPLICA_TAG) ++ ] + +- if reporter.has_errors: +- raise LibraryError() ++ if not _replicas_valid(replica_list): ++ raise BundleDifferentReplicas(bundle_id) + + return BundleStatusDto( + bundle_id, +@@ -209,87 +289,79 @@ def _bundle_to_dto( + bundle_el.get("description"), + is_true(bundle_el.get("managed", "false")), + is_true(bundle_el.get("failed", "false")), +- replicas, ++ replica_list, + ) + + +-_TAG_TO_FUNCTION = { +- _PRIMITIVE_TAG: _primitive_to_dto, +- _GROUP_TAG: _group_to_dto, +- _CLONE_TAG: _clone_to_dto, +- _BUNDLE_TAG: _bundle_to_dto, +-} +- +- +-def status_xml_to_dto( +- reporter: ReportProcessor, status: _Element +-) -> ResourcesStatusDto: +- """ +- Return dto containing status of configured resources in the cluster +- +- reporter -- ReportProcessor +- status -- status xml document from crm_mon, validated using +- the appropriate rng schema +- """ +- resources = cast(list[_Element], status.xpath("resources/*")) +- +- resource_dtos = [ +- _TAG_TO_FUNCTION[resource.tag](reporter, resource) +- for resource in resources +- if resource.tag in _TAG_TO_FUNCTION +- ] ++class ClusterStatusParser: ++ TAG_TO_FUNCTION = { ++ _PRIMITIVE_TAG: _primitive_to_dto, ++ _GROUP_TAG: _group_to_dto, ++ _CLONE_TAG: _clone_to_dto, ++ _BUNDLE_TAG: _bundle_to_dto, ++ } ++ ++ def __init__(self, status: _Element): ++ self.status = status ++ self.warnings: reports.ReportItemList = [] ++ ++ def status_xml_to_dto(self) -> ResourcesStatusDto: ++ """ ++ Return dto containing status of configured resources in the cluster ++ ++ status -- status xml document from crm_mon, validated using ++ the appropriate rng schema ++ """ ++ resource_list = cast(list[_Element], self.status.xpath("resources/*")) ++ ++ resource_dto_list = [] ++ for resource in resource_list: ++ try: ++ resource_dto = cast( ++ AnyResourceStatusDto, ++ self.TAG_TO_FUNCTION[resource.tag](resource), ++ ) ++ resource_dto_list.append(resource_dto) ++ except BundleSameIdAsImplicitResourceError as e: ++ # This is the only error that the user can cause directly by ++ # setting the name of the bundle member to be same as one of ++ # the implicitly created resource. ++ # We only skip such bundles while still providing status of the ++ # other resources. ++ self.warnings.append( ++ reports.ReportItem.warning( ++ reports.messages.ClusterStatusBundleMemberIdAsImplicit( ++ e.bundle_id, e.bad_ids ++ ) ++ ) ++ ) + +- if reporter.has_errors: +- raise LibraryError() ++ return ResourcesStatusDto(resource_dto_list) + +- return ResourcesStatusDto( +- cast( +- list[AnyResourceStatusDto], +- [dto for dto in resource_dtos if dto is not None], +- ) +- ) ++ def get_warnings(self) -> reports.ReportItemList: ++ return self.warnings + + +-def _get_resource_id(reporter: ReportProcessor, resource: _Element) -> str: ++def _get_resource_id(resource: _Element) -> str: + resource_id = resource.get("id") + if not resource_id: +- reporter.report( +- reports.ReportItem.error( +- reports.messages.InvalidIdIsEmpty("resource id") +- ) +- ) ++ raise EmptyResourceIdError() + return str(resource_id) + + +-def _get_role( +- reporter: ReportProcessor, resource: _Element, resource_id: str +-) -> PcmkStatusRoleType: ++def _get_role(resource: _Element) -> PcmkStatusRoleType: + role = resource.get("role") + if role is None or role not in PCMK_STATUS_ROLES: +- reporter.report( +- reports.ReportItem.warning( +- reports.messages.ClusterStatusUnknownPcmkRole(role, resource_id) +- ) +- ) +- return PCMK_STATUS_ROLE_UNKNOWN ++ raise UnknownPcmkRoleError(str(resource.get("id")), str(role)) + return PcmkStatusRoleType(role) + + +-def _get_target_role( +- reporter: ReportProcessor, resource: _Element, resource_id: str +-) -> Optional[PcmkRoleType]: ++def _get_target_role(resource: _Element) -> Optional[PcmkRoleType]: + target_role = resource.get("target_role") + if target_role is None: + return None + if target_role not in PCMK_ROLES: +- reporter.report( +- reports.ReportItem.warning( +- reports.messages.ClusterStatusUnknownPcmkRole( +- target_role, resource_id +- ) +- ) +- ) +- return PCMK_ROLE_UNKNOWN ++ raise UnknownPcmkRoleError(str(resource.get("id")), target_role) + return PcmkRoleType(target_role) + + +@@ -299,130 +371,66 @@ def _remove_clone_suffix(resource_id: str) -> str: + return resource_id + + +-def _validate_mixed_instance_types( +- primitives: list[PrimitiveStatusDto], +- groups: list[GroupStatusDto], +- clone_id: str, +-) -> reports.ReportItemList: +- if primitives and groups: +- return [ +- reports.ReportItem.error( +- reports.messages.ClusterStatusCloneMixedMembers(clone_id) +- ) +- ] +- return [] +- +- +-def _validate_primitive_instance_ids( +- instances: list[PrimitiveStatusDto], clone_id: str +-) -> reports.ReportItemList: +- if len(set(res.resource_id for res in instances)) > 1: +- return [ +- reports.ReportItem.error( +- reports.messages.ClusterStatusCloneMembersDifferentIds(clone_id) +- ) +- ] +- return [] +- +- +-def _validate_group_instance_ids( +- instances: list[GroupStatusDto], clone_id: str +-) -> reports.ReportItemList: +- group_ids = set(group.resource_id for group in instances) +- children_ids = set( +- tuple(child.resource_id for child in group.members) +- for group in instances +- ) +- +- if len(group_ids) > 1 or len(children_ids) > 1: +- return [ +- reports.ReportItem.error( +- reports.messages.ClusterStatusCloneMembersDifferentIds(clone_id) +- ) +- ] +- return [] +- +- + def _replica_to_dto( +- reporter: ReportProcessor, +- replica_el: _Element, +- bundle_id: str, +- bundle_type: str, +-) -> Optional[BundleReplicaStatusDto]: ++ replica_el: _Element, bundle_id: str, bundle_type: str ++) -> BundleReplicaStatusDto: + replica_id = str(replica_el.get("id")) + +- resources = [ +- _primitive_to_dto(reporter, resource) ++ resource_list = [ ++ _primitive_to_dto(resource) + for resource in replica_el.iterfind(_PRIMITIVE_TAG) + ] + +- duplicate_ids = _find_duplicate_ids(resources) ++ duplicate_ids = [ ++ id ++ for id, count in Counter( ++ resource.resource_id for resource in resource_list ++ ).items() ++ if count > 1 ++ ] ++ + if duplicate_ids: +- reporter.report( +- reports.ReportItem.warning( +- reports.messages.ClusterStatusBundleMemberIdAsImplicit( +- bundle_id, duplicate_ids +- ) +- ) +- ) +- return None ++ raise BundleSameIdAsImplicitResourceError(bundle_id, duplicate_ids) + + # TODO pacemaker will probably add prefix + # "pcmk-internal" to all implicit resources + +- container_resource = _get_implicit_resource( +- resources, ++ container_resource = _pop_implicit_resource( ++ resource_list, + f"{bundle_id}-{bundle_type}-{replica_id}", + True, + f"ocf:heartbeat:{bundle_type}", + ) + + if container_resource is None: +- reporter.report( +- reports.ReportItem.error( +- reports.messages.ClusterStatusBundleReplicaNoContainer( +- bundle_id, replica_id +- ) +- ) ++ raise BundleReplicaMissingImplicitResourceError( ++ bundle_id, replica_id, "container" + ) +- raise LibraryError() + +- remote_resource = _get_implicit_resource( +- resources, f"{bundle_id}-{replica_id}", True, "ocf:pacemaker:remote" ++ remote_resource = _pop_implicit_resource( ++ resource_list, f"{bundle_id}-{replica_id}", True, "ocf:pacemaker:remote" + ) + + # implicit ip address resource might be present + ip_resource = None +- if (remote_resource is not None and len(resources) == 2) or ( +- remote_resource is None and len(resources) == 1 ++ if (remote_resource is not None and len(resource_list) == 2) or ( ++ remote_resource is None and len(resource_list) == 1 + ): +- ip_resource = _get_implicit_resource( +- resources, f"{bundle_id}-ip-", False, "ocf:heartbeat:IPaddr2" ++ ip_resource = _pop_implicit_resource( ++ resource_list, f"{bundle_id}-ip-", False, "ocf:heartbeat:IPaddr2" + ) + +- if remote_resource is None and resources: +- reporter.report( +- reports.ReportItem.error( +- reports.messages.ClusterStatusBundleReplicaMissingRemote( +- bundle_id, replica_id +- ) +- ) ++ if remote_resource is None and resource_list: ++ raise BundleReplicaMissingImplicitResourceError( ++ bundle_id, replica_id, "remote" + ) +- raise LibraryError() + + member = None + if remote_resource: +- if len(resources) == 1: +- member = resources[0] ++ if len(resource_list) == 1: ++ member = resource_list[0] + else: +- reporter.report( +- reports.ReportItem.error( +- reports.messages.ClusterStatusBundleReplicaInvalidCount( +- bundle_id, replica_id +- ) +- ) +- ) +- raise LibraryError() ++ raise BundleReplicaInvalidMemberCountError(bundle_id, replica_id) + + return BundleReplicaStatusDto( + replica_id, +@@ -433,24 +441,13 @@ def _replica_to_dto( + ) + + +-def _find_duplicate_ids(resources: Sequence[AnyResourceStatusDto]) -> list[str]: +- seen = set() +- duplicates = [] +- for resource in resources: +- if resource.resource_id in seen: +- duplicates.append(resource.resource_id) +- else: +- seen.add(resource.resource_id) +- return duplicates +- +- +-def _get_implicit_resource( +- primitives: list[PrimitiveStatusDto], ++def _pop_implicit_resource( ++ primitive_list: list[PrimitiveStatusDto], + expected_id: str, + exact_match: bool, + resource_agent: str, + ) -> Optional[PrimitiveStatusDto]: +- for primitive in primitives: ++ for primitive in primitive_list: + matching_id = ( + exact_match + and primitive.resource_id == expected_id +@@ -459,36 +456,28 @@ def _get_implicit_resource( + ) + + if matching_id and primitive.resource_agent == resource_agent: +- primitives.remove(primitive) ++ primitive_list.remove(primitive) + return primitive + + return None + + +-def _validate_replicas( +- replicas: Sequence[BundleReplicaStatusDto], bundle_id: str +-) -> reports.ReportItemList: +- if not replicas: +- return [] ++def _replicas_valid(replica_list: Sequence[BundleReplicaStatusDto]) -> bool: ++ if not replica_list: ++ return True + +- member = replicas[0].member +- ip = replicas[0].ip_address +- container = replicas[0].container ++ member = replica_list[0].member ++ ip = replica_list[0].ip_address ++ container = replica_list[0].container + +- for replica in replicas: ++ for replica in replica_list: + if ( + not _cmp_replica_members(member, replica.member, True) + or not _cmp_replica_members(ip, replica.ip_address, False) + or not _cmp_replica_members(container, replica.container, False) + ): +- return [ +- reports.ReportItem.error( +- reports.messages.ClusterStatusBundleDifferentReplicas( +- bundle_id +- ) +- ) +- ] +- return [] ++ return False ++ return True + + + def _cmp_replica_members( +diff --git a/pcs_test/resources/crm_mon.all_resources.xml b/pcs_test/resources/crm_mon.all_resources.xml +index e493d308..f11db064 100644 +--- a/pcs_test/resources/crm_mon.all_resources.xml ++++ b/pcs_test/resources/crm_mon.all_resources.xml +@@ -1,40 +1,53 @@ +- ++ + +- +- +- +- +- +- ++ ++ ++ ++ ++ ++ + + +- +- +- ++ + +- +- +- +- +- +- +- +- +- +- +- +- +- +- ++ + +- +- +- +- +- +- ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + +- ++ ++ ++ ++ ++ + +diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py +index b60360e4..48eb730c 100644 +--- a/pcs_test/tier0/common/reports/test_messages.py ++++ b/pcs_test/tier0/common/reports/test_messages.py +@@ -2195,6 +2195,26 @@ class BadClusterStateFormat(NameBuildTest): + ) + + ++class BadClusterState(NameBuildTest): ++ def test_no_reason(self): ++ self.assert_message_from_report( ++ ( ++ "Cannot load cluster status, xml does not describe " ++ "valid cluster status." ++ ), ++ reports.BadClusterState(), ++ ) ++ ++ def test_reason(self): ++ self.assert_message_from_report( ++ ( ++ "Cannot load cluster status, xml does not describe " ++ "valid cluster status: sample reason." ++ ), ++ reports.BadClusterState("sample reason"), ++ ) ++ ++ + class WaitForIdleStarted(NameBuildTest): + def test_timeout(self): + timeout = 20 +@@ -5818,143 +5838,26 @@ class CannotCreateDefaultClusterPropertySet(NameBuildTest): + ) + + +-class ClusterStatusBundleDifferentReplicas(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- "Replicas of bundle 'bundle' are not the same.", +- reports.ClusterStatusBundleDifferentReplicas("bundle"), +- ) +- +- + class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest): +- def test_message(self): ++ def test_one(self): + self.assert_message_from_report( + ( +- "Skipping bundle 'bundle': resource 'test' has " +- "the same id as some of the implicit bundle resources." +- ), +- reports.ClusterStatusBundleMemberIdAsImplicit("bundle", ["test"]), +- ) +- +- def test_multiple_ids(self): +- self.assert_message_from_report( +- ( +- "Skipping bundle 'bundle': resources 'test1', 'test2' have " ++ "Skipping bundle 'resource-bundle': resource 'resource' has " + "the same id as some of the implicit bundle resources." + ), + reports.ClusterStatusBundleMemberIdAsImplicit( +- "bundle", ["test1", "test2"] +- ), +- ) +- +- +-class ClusterStatusBundleReplicaInvalidCount(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- ( +- "Replica '0' of bundle 'bundle' has invalid number of members. " +- "Expecting 2-4 members." ++ "resource-bundle", ["resource"] + ), +- reports.ClusterStatusBundleReplicaInvalidCount("bundle", "0"), + ) + +- +-class ClusterStatusBundleReplicaMissingRemote(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- ( +- "Replica '0' of bundle 'bundle' is missing implicit pacemaker " +- "remote resource while it must be present." +- ), +- reports.ClusterStatusBundleReplicaMissingRemote("bundle", "0"), +- ) +- +- +-class ClusterStatusBundleReplicaNoContainer(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- ( +- "Replica '0' of bundle 'bundle' is missing implicit container " +- "resource." +- ), +- reports.ClusterStatusBundleReplicaNoContainer("bundle", "0"), +- ) +- +- +-class ClusterStatusCloneMembersDifferentIds(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- "Members with different ids in clone 'clone'.", +- reports.ClusterStatusCloneMembersDifferentIds("clone"), +- ) +- +- +-class ClusterStatusCloneMixedMembers(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- "Primitive and group members mixed in clone 'clone'.", +- reports.ClusterStatusCloneMixedMembers("clone"), +- ) +- +- +-class ClusterStatusEmptyNodeName(NameBuildTest): +- def test_message(self): +- self.assert_message_from_report( +- "Resource with id 'resource' contains node with empty name.", +- reports.ClusterStatusEmptyNodeName("resource"), +- ) +- +- +-class ClusterStatusUnexpectedMember(NameBuildTest): +- def test_one_expected(self): +- self.assert_message_from_report( +- ( +- "Unexpected resource 'member' inside of resource 'resource' of " +- "type 'group'. Only resources of type 'primitive' " +- "can be in group." +- ), +- reports.ClusterStatusUnexpectedMember( +- resource_id="resource", +- resource_type="group", +- member_id="member", +- expected_types=["primitive"], +- ), +- ) +- +- def test_multiple_expected(self): ++ def test_multiple(self): + self.assert_message_from_report( + ( +- "Unexpected resource 'member' inside of resource 'resource' of " +- "type 'clone'. Only resources of type 'group'|'primitive' " +- "can be in clone." +- ), +- reports.ClusterStatusUnexpectedMember( +- resource_id="resource", +- resource_type="clone", +- member_id="member", +- expected_types=["primitive", "group"], ++ "Skipping bundle 'resource-bundle': resources 'resource-0', " ++ "'resource-1' have the same id as some of the implicit bundle " ++ "resources." + ), +- ) +- +- +-class ClusterStatusUnknownPcmkRole(NameBuildTest): +- def test_no_role(self): +- self.assert_message_from_report( +- "Attribute of resource with id 'resource' contains empty pcmk role.", +- reports.ClusterStatusUnknownPcmkRole(None, "resource"), +- ) +- +- def test_empty_role(self): +- self.assert_message_from_report( +- "Attribute of resource with id 'resource' contains empty pcmk role.", +- reports.ClusterStatusUnknownPcmkRole("", "resource"), +- ) +- +- def test_role(self): +- self.assert_message_from_report( +- ( +- "Attribute of resource with id 'resource' contains invalid " +- "pcmk role 'NotValidRole'." ++ reports.ClusterStatusBundleMemberIdAsImplicit( ++ "resource-bundle", ["resource-0", "resource-1"] + ), +- reports.ClusterStatusUnknownPcmkRole("NotValidRole", "resource"), + ) +diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py +index a5a395b5..3b6b7665 100644 +--- a/pcs_test/tier0/lib/commands/test_status.py ++++ b/pcs_test/tier0/lib/commands/test_status.py +@@ -1,5 +1,7 @@ ++# pylint: disable=too-many-lines + import os + from textwrap import dedent ++from typing import Optional + from unittest import ( + TestCase, + mock, +@@ -7,7 +9,11 @@ from unittest import ( + + from pcs import settings + from pcs.common import file_type_codes +-from pcs.common.const import PCMK_STATUS_ROLE_STARTED ++from pcs.common.const import ( ++ PCMK_ROLE_STOPPED, ++ PCMK_STATUS_ROLE_STOPPED, ++ PcmkRoleType, ++) + from pcs.common.reports import codes as report_codes + from pcs.common.status_dto import ( + BundleReplicaStatusDto, +@@ -1267,24 +1273,27 @@ class FullClusterStatusPlaintextBoothWarning(FullClusterStatusPlaintextBase): + + + def _fixture_primitive_resource_dto( +- resource_id: str, resource_agent: str ++ resource_id: str, ++ resource_agent: str, ++ target_role: Optional[PcmkRoleType] = None, ++ managed: bool = True, + ) -> PrimitiveStatusDto: + return PrimitiveStatusDto( +- resource_id, +- resource_agent, +- PCMK_STATUS_ROLE_STARTED, +- None, +- True, +- False, +- False, +- False, +- None, +- False, +- True, +- False, +- ["node1"], +- None, +- None, ++ resource_id=resource_id, ++ resource_agent=resource_agent, ++ role=PCMK_STATUS_ROLE_STOPPED, ++ target_role=target_role, ++ active=False, ++ orphaned=False, ++ blocked=False, ++ maintenance=False, ++ description=None, ++ failed=False, ++ managed=managed, ++ failure_ignored=False, ++ node_names=[], ++ pending=None, ++ locked_to=None, + ) + + +@@ -1303,7 +1312,7 @@ class ResourcesStatus(TestCase): + result = status.resources_status(self.env_assist.get_env()) + self.assertEqual(result, ResourcesStatusDto([])) + +- def test_bad_xml(self): ++ def test_bad_xml_format(self): + self.config.runner.pcmk.load_state( + resources=""" + +@@ -1320,6 +1329,26 @@ class ResourcesStatus(TestCase): + False, + ) + ++ def test_bad_xml(self): ++ self.config.runner.pcmk.load_state( ++ resources=""" ++ ++ ++ ++ """, ++ ) ++ ++ self.env_assist.assert_raise_library_error( ++ lambda: status.resources_status(self.env_assist.get_env()), ++ [ ++ fixture.error( ++ report_codes.BAD_CLUSTER_STATE, ++ reason="Resource with id 'R7' contains unknown pcmk role 'NotPcmkRole'.", ++ ), ++ ], ++ False, ++ ) ++ + def test_all_resources(self): + self.config.runner.pcmk.load_state( + filename=rc("crm_mon.all_resources.xml") +@@ -1327,69 +1356,187 @@ class ResourcesStatus(TestCase): + + result = status.resources_status(self.env_assist.get_env()) + +- self.assertTrue(len(result.resources) == 4) + self.assertEqual( +- result.resources[0], +- _fixture_primitive_resource_dto("dummy", "ocf:pacemaker:Dummy"), +- ) +- self.assertEqual( +- result.resources[1], +- GroupStatusDto( +- "group", +- False, +- None, +- True, +- False, +- members=[ ++ result, ++ ResourcesStatusDto( ++ [ ++ BundleStatusDto( ++ resource_id="B1", ++ type="docker", ++ image="pcs:test", ++ unique=True, ++ maintenance=False, ++ description=None, ++ managed=False, ++ failed=False, ++ replicas=[ ++ BundleReplicaStatusDto( ++ replica_id="0", ++ member=None, ++ remote=None, ++ container=_fixture_primitive_resource_dto( ++ "B1-docker-0", ++ "ocf:heartbeat:docker", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ip_address=_fixture_primitive_resource_dto( ++ "B1-ip-192.168.100.200", ++ "ocf:heartbeat:IPaddr2", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ), ++ BundleReplicaStatusDto( ++ replica_id="1", ++ member=None, ++ remote=None, ++ container=_fixture_primitive_resource_dto( ++ "B1-docker-1", ++ "ocf:heartbeat:docker", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ip_address=_fixture_primitive_resource_dto( ++ "B1-ip-192.168.100.201", ++ "ocf:heartbeat:IPaddr2", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ), ++ BundleReplicaStatusDto( ++ replica_id="2", ++ member=None, ++ remote=None, ++ container=_fixture_primitive_resource_dto( ++ "B1-docker-2", ++ "ocf:heartbeat:docker", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ip_address=_fixture_primitive_resource_dto( ++ "B1-ip-192.168.100.202", ++ "ocf:heartbeat:IPaddr2", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ), ++ BundleReplicaStatusDto( ++ replica_id="3", ++ member=None, ++ remote=None, ++ container=_fixture_primitive_resource_dto( ++ "B1-docker-3", ++ "ocf:heartbeat:docker", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ip_address=_fixture_primitive_resource_dto( ++ "B1-ip-192.168.100.203", ++ "ocf:heartbeat:IPaddr2", ++ target_role=PCMK_ROLE_STOPPED, ++ managed=False, ++ ), ++ ), ++ ], ++ ), + _fixture_primitive_resource_dto( +- "grouped", "ocf:pacemaker:Dummy" +- ) +- ], +- ), +- ) +- self.assertEqual( +- result.resources[2], +- CloneStatusDto( +- "clone", +- False, +- False, +- False, +- None, +- True, +- False, +- False, +- False, +- None, +- instances=[ ++ "R7", "ocf:pacemaker:Dummy" ++ ), + _fixture_primitive_resource_dto( +- "cloned", "ocf:pacemaker:Dummy" +- ) +- ], ++ "S2", "stonith:fence_kdump" ++ ), ++ GroupStatusDto( ++ resource_id="G2", ++ maintenance=False, ++ description=None, ++ managed=True, ++ disabled=False, ++ members=[ ++ _fixture_primitive_resource_dto( ++ "R5", "ocf:pacemaker:Dummy" ++ ), ++ _fixture_primitive_resource_dto( ++ "S1", "stonith:fence_kdump" ++ ), ++ ], ++ ), ++ CloneStatusDto( ++ resource_id="G1-clone", ++ multi_state=True, ++ unique=False, ++ maintenance=False, ++ description=None, ++ managed=True, ++ disabled=False, ++ failed=False, ++ failure_ignored=False, ++ target_role=None, ++ instances=[ ++ GroupStatusDto( ++ resource_id="G1", ++ maintenance=False, ++ description=None, ++ managed=True, ++ disabled=False, ++ members=[ ++ _fixture_primitive_resource_dto( ++ "R2", "ocf:pacemaker:Stateful" ++ ), ++ _fixture_primitive_resource_dto( ++ "R3", "ocf:pacemaker:Stateful" ++ ), ++ _fixture_primitive_resource_dto( ++ "R4", "ocf:pacemaker:Stateful" ++ ), ++ ], ++ ) ++ ], ++ ), ++ CloneStatusDto( ++ resource_id="R6-clone", ++ multi_state=False, ++ unique=False, ++ maintenance=False, ++ description=None, ++ managed=True, ++ disabled=False, ++ failed=False, ++ failure_ignored=False, ++ target_role=None, ++ instances=[ ++ _fixture_primitive_resource_dto( ++ "R6", "ocf:pacemaker:Dummy" ++ ) ++ ], ++ ), ++ ] + ), + ) +- self.assertEqual( +- result.resources[3], +- BundleStatusDto( +- "bundle", +- "podman", +- "localhost/pcmktest:http", +- False, +- False, +- None, +- True, +- False, +- [ +- BundleReplicaStatusDto( +- "0", +- None, +- None, +- _fixture_primitive_resource_dto( +- "bundle-podman-0", "ocf:heartbeat:podman" +- ), +- _fixture_primitive_resource_dto( +- "bundle-ip-192.168.122.250", "ocf:heartbeat:IPaddr2" +- ), +- ) +- ], +- ), ++ ++ def test_bundle_skip(self): ++ self.config.runner.pcmk.load_state( ++ resources=""" ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ """, ++ ) ++ ++ result = status.resources_status(self.env_assist.get_env()) ++ self.assertEqual(result, ResourcesStatusDto([])) ++ self.env_assist.assert_reports( ++ [ ++ fixture.warn( ++ report_codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, ++ bundle_id="B1", ++ bad_ids=["B1-0"], ++ ) ++ ] + ) +diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py +index 451fb584..778e97a6 100644 +--- a/pcs_test/tier0/lib/pacemaker/test_status.py ++++ b/pcs_test/tier0/lib/pacemaker/test_status.py +@@ -11,11 +11,9 @@ from lxml import etree + from pcs.common import reports + from pcs.common.const import ( + PCMK_ROLE_STARTED, +- PCMK_ROLE_UNKNOWN, + PCMK_ROLES, + PCMK_STATUS_ROLE_STARTED, + PCMK_STATUS_ROLE_STOPPED, +- PCMK_STATUS_ROLE_UNKNOWN, + PCMK_STATUS_ROLE_UNPROMOTED, + PCMK_STATUS_ROLES, + PCMK_STATUS_ROLES_PENDING, +@@ -34,10 +32,9 @@ from pcs.lib.pacemaker import status + + from pcs_test.tools import fixture + from pcs_test.tools.assertions import ( +- assert_raise_library_error, ++ assert_report_item_equal, + assert_report_item_list_equal, + ) +-from pcs_test.tools.custom_mock import MockLibraryReportProcessor + + + def fixture_primitive_xml( +@@ -327,34 +324,163 @@ def fixture_crm_mon_xml(resources: list[str]) -> str: + """ + + ++class TestParsingErrorToReport(TestCase): ++ # pylint: disable=no-self-use ++ ++ def test_empty_resource_id(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.EmptyResourceIdError() ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Resource with empty id.", ++ ), ++ ) ++ ++ def test_empty_node_name(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.EmptyNodeNameError("resource") ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Resource with id 'resource' contains node with empty name.", ++ ), ++ ) ++ ++ def test_unknow_pcmk_role(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.UnknownPcmkRoleError("resource", "NotPcmkRole") ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Resource with id 'resource' contains unknown pcmk role 'NotPcmkRole'.", ++ ), ++ ) ++ ++ def test_unexpected_member_group(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.UnexpectedMemberError( ++ "resource", "group", "member", ["primitive"] ++ ) ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason=( ++ "Unexpected resource 'member' inside of resource " ++ "'resource' of type 'group'. Only resources of type " ++ "'primitive' can be in group." ++ ), ++ ), ++ ) ++ ++ def test_unexpected_member_clone(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.UnexpectedMemberError( ++ "resource", "clone", "member", ["primitive", "group"] ++ ) ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason=( ++ "Unexpected resource 'member' inside of resource " ++ "'resource' of type 'clone'. Only resources of type " ++ "'group'|'primitive' can be in clone." ++ ), ++ ), ++ ) ++ ++ def test_mixed_members(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.MixedMembersError("resource") ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Primitive and group members mixed in clone 'resource'.", ++ ), ++ ) ++ ++ def test_different_member_ids(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.DifferentMemberIdsError("resource") ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Members with different ids in resource 'resource'.", ++ ), ++ ) ++ ++ def test_bundle_replica_missing_implicit(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.BundleReplicaMissingImplicitResourceError( ++ "resource", "0", "container" ++ ) ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Replica '0' of bundle 'resource' is missing implicit container resource.", ++ ), ++ ) ++ ++ def test_bundle_replica_invalid_member_count(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.BundleReplicaInvalidMemberCountError("resource", "0") ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Replica '0' of bundle 'resource' has invalid number of members.", ++ ), ++ ) ++ ++ def test_bundle_different_replicas(self): ++ report = status.cluster_status_parsing_error_to_report( ++ status.BundleDifferentReplicas("resource") ++ ) ++ assert_report_item_equal( ++ report, ++ fixture.error( ++ reports.codes.BAD_CLUSTER_STATE, ++ reason="Replicas of bundle 'resource' are not the same.", ++ ), ++ ) ++ ++ + class TestPrimitiveStatusToDto(TestCase): + # pylint: disable=protected-access +- def setUp(self): +- self.report_processor = MockLibraryReportProcessor() +- + def test_simple(self): + primitive_xml = etree.fromstring(fixture_primitive_xml()) + +- result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ result = status._primitive_to_dto(primitive_xml) + + self.assertEqual(result, fixture_primitive_dto()) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_empty_node_list(self): + primitive_xml = etree.fromstring( + fixture_primitive_xml(role=PCMK_STATUS_ROLE_STOPPED, node_names=[]) + ) +- result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ result = status._primitive_to_dto(primitive_xml) + + self.assertEqual( + result, + fixture_primitive_dto(role=PCMK_STATUS_ROLE_STOPPED, node_names=[]), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_all_attributes(self): + primitive_xml = etree.fromstring( +@@ -363,7 +489,7 @@ class TestPrimitiveStatusToDto(TestCase): + ) + ) + +- result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ result = status._primitive_to_dto(primitive_xml) + + self.assertEqual( + result, +@@ -371,74 +497,40 @@ class TestPrimitiveStatusToDto(TestCase): + target_role=PCMK_STATUS_ROLE_STOPPED, add_optional_args=True + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_remove_clone_suffix(self): + primitive_xml = etree.fromstring( + fixture_primitive_xml(resource_id="resource:0") + ) + +- result = status._primitive_to_dto( +- self.report_processor, primitive_xml, True +- ) ++ result = status._primitive_to_dto(primitive_xml, True) + + self.assertEqual(result, fixture_primitive_dto()) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_running_on_multiple_nodes(self): + primitive_xml = etree.fromstring( + fixture_primitive_xml(node_names=["node1", "node2", "node3"]) + ) + +- result = status._primitive_to_dto(self.report_processor, primitive_xml) ++ result = status._primitive_to_dto(primitive_xml) + + self.assertEqual( + result, + fixture_primitive_dto(node_names=["node1", "node2", "node3"]), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_empty_node_name(self): + primitive_xml = etree.fromstring(fixture_primitive_xml(node_names=[""])) + +- assert_raise_library_error( +- lambda: status._primitive_to_dto( +- self.report_processor, primitive_xml +- ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_EMPTY_NODE_NAME, +- resource_id="resource", +- ) +- ], +- ) ++ with self.assertRaises(status.EmptyNodeNameError) as cm: ++ status._primitive_to_dto(primitive_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") + + def test_empty_resource_id(self): + primitive_xml = etree.fromstring(fixture_primitive_xml(resource_id="")) + +- assert_raise_library_error( +- lambda: status._primitive_to_dto( +- self.report_processor, primitive_xml +- ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.INVALID_ID_IS_EMPTY, +- id_description="resource id", +- ) +- ], +- ) ++ with self.assertRaises(status.EmptyResourceIdError): ++ status._primitive_to_dto(primitive_xml) + + def test_role(self): + for role in PCMK_STATUS_ROLES: +@@ -446,35 +538,17 @@ class TestPrimitiveStatusToDto(TestCase): + primitive_xml = etree.fromstring( + fixture_primitive_xml(role=role) + ) +- +- result = status._primitive_to_dto( +- self.report_processor, primitive_xml +- ) ++ result = status._primitive_to_dto(primitive_xml) + self.assertEqual(result, fixture_primitive_dto(role=role)) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_invalid_role(self): + primitive_xml = etree.fromstring( + fixture_primitive_xml(role="NotPcmkRole") + ) + +- result = status._primitive_to_dto(self.report_processor, primitive_xml) +- +- self.assertEqual( +- result, fixture_primitive_dto(role=PCMK_STATUS_ROLE_UNKNOWN) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE, +- role="NotPcmkRole", +- resource_id="resource", +- ) +- ], +- ) ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._primitive_to_dto(primitive_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") + + def test_target_role(self): + for role in PCMK_ROLES: +@@ -483,76 +557,47 @@ class TestPrimitiveStatusToDto(TestCase): + fixture_primitive_xml(target_role=role) + ) + +- result = status._primitive_to_dto( +- self.report_processor, primitive_xml +- ) ++ result = status._primitive_to_dto(primitive_xml) + + self.assertEqual( + result, fixture_primitive_dto(target_role=role) + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_invalid_target_role(self): + for value in PCMK_STATUS_ROLES_PENDING + ("NotPcmkRole",): + with self.subTest(value=value): +- self.setUp() + primitive_xml = etree.fromstring( + fixture_primitive_xml(target_role=value) + ) + +- result = status._primitive_to_dto( +- self.report_processor, primitive_xml +- ) +- +- self.assertEqual( +- result, fixture_primitive_dto(target_role=PCMK_ROLE_UNKNOWN) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE, +- role=value, +- resource_id="resource", +- ) +- ], +- ) ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._primitive_to_dto(primitive_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") + + + class TestGroupStatusToDto(TestCase): + # pylint: disable=protected-access +- def setUp(self): +- self.report_processor = MockLibraryReportProcessor() +- + def test_all_attributes(self): + group_xml = etree.fromstring( + fixture_group_xml(description="Test description") + ) + +- result = status._group_to_dto(self.report_processor, group_xml) ++ result = status._group_to_dto(group_xml) + + self.assertEqual( + result, fixture_group_dto(description="Test description") + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_single_member(self): + group_xml = etree.fromstring( + fixture_group_xml(members=[fixture_primitive_xml()]) + ) + +- result = status._group_to_dto(self.report_processor, group_xml) ++ result = status._group_to_dto(group_xml) + + self.assertEqual( + result, fixture_group_dto(members=[fixture_primitive_dto()]) + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_multiple_members(self): + group_xml = etree.fromstring( +@@ -564,7 +609,7 @@ class TestGroupStatusToDto(TestCase): + ) + ) + +- result = status._group_to_dto(self.report_processor, group_xml) ++ result = status._group_to_dto(group_xml) + + self.assertEqual( + result, +@@ -575,9 +620,6 @@ class TestGroupStatusToDto(TestCase): + ] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_multiple_members_different_state(self): + group_xml = etree.fromstring( +@@ -594,7 +636,7 @@ class TestGroupStatusToDto(TestCase): + ) + ) + +- result = status._group_to_dto(self.report_processor, group_xml) ++ result = status._group_to_dto(group_xml) + + self.assertEqual( + result, +@@ -610,10 +652,31 @@ class TestGroupStatusToDto(TestCase): + ] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] ++ ++ def test_member_invalid_role(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml( ++ members=[fixture_primitive_xml(role="NotPcmkRole")] ++ ) ++ ) ++ ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._group_to_dto(group_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") ++ ++ def test_member_invalid_target_role(self): ++ group_xml = etree.fromstring( ++ fixture_group_xml( ++ members=[fixture_primitive_xml(target_role="NotPcmkRole")] ++ ) + ) + ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._group_to_dto(group_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") ++ + def test_invalid_member(self): + resources = { + "inner-group": '', +@@ -623,31 +686,17 @@ class TestGroupStatusToDto(TestCase): + + for resource_id, member in resources.items(): + with self.subTest(value=resource_id): +- self.setUp() + group_xml = etree.fromstring( + fixture_group_xml( + resource_id="outer-group", members=[member] + ) + ) + +- # pylint: disable=cell-var-from-loop +- assert_raise_library_error( +- lambda: status._group_to_dto( +- self.report_processor, group_xml +- ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_UNEXPECTED_MEMBER, +- resource_id="outer-group", +- resource_type="group", +- member_id=resource_id, +- expected_types=["primitive"], +- ) +- ], +- ) ++ with self.assertRaises(status.UnexpectedMemberError) as cm: ++ status._group_to_dto(group_xml) ++ self.assertEqual(cm.exception.resource_id, "outer-group") ++ self.assertEqual(cm.exception.member_id, resource_id) ++ self.assertEqual(cm.exception.expected_types, ["primitive"]) + + def test_remove_clone_suffix(self): + group_xml = etree.fromstring( +@@ -657,21 +706,15 @@ class TestGroupStatusToDto(TestCase): + ) + ) + +- result = status._group_to_dto(self.report_processor, group_xml, True) ++ result = status._group_to_dto(group_xml, True) + self.assertEqual( + result, + fixture_group_dto(members=[fixture_primitive_dto()]), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + + class TestCloneStatusToDto(TestCase): + # pylint: disable=protected-access +- def setUp(self): +- self.report_processor = MockLibraryReportProcessor() +- + def test_all_attributes(self): + clone_xml = etree.fromstring( + fixture_clone_xml( +@@ -680,7 +723,7 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, +@@ -688,23 +731,17 @@ class TestCloneStatusToDto(TestCase): + description="Test description", target_role=PCMK_ROLE_STARTED + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_primitive_member(self): + clone_xml = etree.fromstring( + fixture_clone_xml(instances=[fixture_primitive_xml()]) + ) + +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, fixture_clone_dto(instances=[fixture_primitive_dto()]) + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_primitive_member_multiple(self): + clone_xml = etree.fromstring( +@@ -716,7 +753,7 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, +@@ -727,9 +764,6 @@ class TestCloneStatusToDto(TestCase): + ] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_primitive_member_unique(self): + clone_xml = etree.fromstring( +@@ -744,7 +778,7 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, +@@ -756,9 +790,6 @@ class TestCloneStatusToDto(TestCase): + ], + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_primitive_member_promotable(self): + clone_xml = etree.fromstring( +@@ -772,7 +803,7 @@ class TestCloneStatusToDto(TestCase): + ], + ) + ) +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, +@@ -786,10 +817,31 @@ class TestCloneStatusToDto(TestCase): + ], + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] ++ ++ def test_primitive_member_invalid_role(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[fixture_primitive_xml(role="NotPcmkRole")] ++ ) ++ ) ++ ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._clone_to_dto(clone_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") ++ ++ def test_primitive_member_invalid_target_role(self): ++ clone_xml = etree.fromstring( ++ fixture_clone_xml( ++ instances=[fixture_primitive_xml(target_role="NotPcmkRole")] ++ ) + ) + ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._clone_to_dto(clone_xml) ++ self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") ++ + def test_primitive_member_different_ids(self): + clone_xml = etree.fromstring( + fixture_clone_xml( +@@ -802,18 +854,9 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- assert_raise_library_error( +- lambda: status._clone_to_dto(self.report_processor, clone_xml) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS, +- clone_id="resource-clone", +- ) +- ], +- ) ++ with self.assertRaises(status.DifferentMemberIdsError) as cm: ++ status._clone_to_dto(clone_xml) ++ self.assertEqual(cm.exception.resource_id, "resource-clone") + + def test_group_member(self): + clone_xml = etree.fromstring( +@@ -830,7 +873,7 @@ class TestCloneStatusToDto(TestCase): + ], + ) + ) +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, +@@ -843,9 +886,6 @@ class TestCloneStatusToDto(TestCase): + ], + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_group_member_unique(self): + clone_xml = etree.fromstring( +@@ -869,7 +909,7 @@ class TestCloneStatusToDto(TestCase): + ], + ) + ) +- result = status._clone_to_dto(self.report_processor, clone_xml) ++ result = status._clone_to_dto(clone_xml) + + self.assertEqual( + result, +@@ -883,9 +923,6 @@ class TestCloneStatusToDto(TestCase): + ], + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_group_member_different_group_ids(self): + clone_xml = etree.fromstring( +@@ -903,18 +940,9 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- assert_raise_library_error( +- lambda: status._clone_to_dto(self.report_processor, clone_xml) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS, +- clone_id="resource-clone", +- ) +- ], +- ) ++ with self.assertRaises(status.DifferentMemberIdsError) as cm: ++ status._clone_to_dto(clone_xml) ++ self.assertEqual(cm.exception.resource_id, "resource-clone") + + def test_group_member_different_primitive_ids(self): + clone_xml = etree.fromstring( +@@ -937,18 +965,9 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- assert_raise_library_error( +- lambda: status._clone_to_dto(self.report_processor, clone_xml) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS, +- clone_id="resource-clone", +- ) +- ], +- ) ++ with self.assertRaises(status.DifferentMemberIdsError) as cm: ++ status._clone_to_dto(clone_xml) ++ self.assertEqual(cm.exception.resource_id, "resource-clone") + + def test_primitive_member_types_mixed(self): + clone_xml = etree.fromstring( +@@ -965,18 +984,9 @@ class TestCloneStatusToDto(TestCase): + ) + ) + +- assert_raise_library_error( +- lambda: status._clone_to_dto(self.report_processor, clone_xml) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_CLONE_MIXED_MEMBERS, +- clone_id="resource-clone", +- ) +- ], +- ) ++ with self.assertRaises(status.MixedMembersError) as cm: ++ status._clone_to_dto(clone_xml) ++ self.assertEqual(cm.exception.resource_id, "resource-clone") + + def test_invalid_member(self): + resources = { +@@ -985,61 +995,43 @@ class TestCloneStatusToDto(TestCase): + } + for resource_id, element in resources.items(): + with self.subTest(value=resource_id): +- self.setUp() + clone_xml = etree.fromstring( +- fixture_clone_xml(instances=[element]) +- ) +- +- # pylint: disable=cell-var-from-loop +- assert_raise_library_error( +- lambda: status._clone_to_dto( +- self.report_processor, clone_xml ++ fixture_clone_xml( ++ resource_id="outer-clone", instances=[element] + ) + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_UNEXPECTED_MEMBER, +- resource_id="resource-clone", +- resource_type="clone", +- member_id=resource_id, +- expected_types=["primitive", "group"], +- ) +- ], ++ ++ with self.assertRaises(status.UnexpectedMemberError) as cm: ++ status._clone_to_dto(clone_xml) ++ ++ self.assertEqual(cm.exception.resource_id, "outer-clone") ++ self.assertEqual(cm.exception.member_id, resource_id) ++ self.assertEqual( ++ cm.exception.expected_types, ["primitive", "group"] + ) + + + class TestBundleReplicaStatusToDto(TestCase): + # pylint: disable=protected-access + def setUp(self): +- self.report_processor = MockLibraryReportProcessor() ++ self.bundle_id = "resource-bundle" ++ self.bundle_type = "podman" + + def test_no_member_no_ip(self): + replica_xml = etree.fromstring(fixture_replica_xml()) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" + result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ replica_xml, self.bundle_id, self.bundle_type + ) + self.assertEqual(result, fixture_replica_dto()) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_no_member(self): + replica_xml = etree.fromstring(fixture_replica_xml(ip=True)) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" + result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ replica_xml, self.bundle_id, self.bundle_type + ) + self.assertEqual(result, fixture_replica_dto(ip=True)) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_member(self): + replica_xml = etree.fromstring( +@@ -1051,10 +1043,8 @@ class TestBundleReplicaStatusToDto(TestCase): + ) + ) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" + result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ replica_xml, self.bundle_id, self.bundle_type + ) + self.assertEqual( + result, +@@ -1063,9 +1053,6 @@ class TestBundleReplicaStatusToDto(TestCase): + member=fixture_primitive_dto(node_names=["resource-bundle-0"]), + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_member_no_ip(self): + replica_xml = etree.fromstring( +@@ -1076,10 +1063,8 @@ class TestBundleReplicaStatusToDto(TestCase): + ) + ) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" + result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ replica_xml, self.bundle_id, self.bundle_type + ) + self.assertEqual( + result, +@@ -1087,10 +1072,35 @@ class TestBundleReplicaStatusToDto(TestCase): + member=fixture_primitive_dto(node_names=["resource-bundle-0"]) + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] ++ ++ def test_invalid_role(self): ++ replica_xml = etree.fromstring( ++ fixture_replica_xml( ++ member=fixture_primitive_xml(role="NotPcmkRole") ++ ) ++ ) ++ ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type ++ ) ++ self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") ++ ++ def test_invalid_target_role(self): ++ replica_xml = etree.fromstring( ++ fixture_replica_xml( ++ member=fixture_primitive_xml(target_role="NotPcmkRole") ++ ) + ) + ++ with self.assertRaises(status.UnknownPcmkRoleError) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type ++ ) ++ self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") ++ + def test_no_container(self): + replica_xml = etree.fromstring( + """ +@@ -1108,44 +1118,28 @@ class TestBundleReplicaStatusToDto(TestCase): + """ + ) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- assert_raise_library_error( +- lambda: status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ with self.assertRaises( ++ status.BundleReplicaMissingImplicitResourceError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type + ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER, +- bundle_id=bundle_id, +- replica_id="0", +- ) +- ], +- ) ++ self.assertEqual(cm.exception.resource_id, self.bundle_id) ++ self.assertEqual(cm.exception.replica_id, "0") ++ self.assertEqual(cm.exception.implicit_type, "container") + + def test_empty_replica(self): + replica_xml = etree.fromstring('') + +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- assert_raise_library_error( +- lambda: status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ with self.assertRaises( ++ status.BundleReplicaMissingImplicitResourceError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type + ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER, +- bundle_id=bundle_id, +- replica_id="0", +- ) +- ], +- ) ++ self.assertEqual(cm.exception.resource_id, self.bundle_id) ++ self.assertEqual(cm.exception.replica_id, "0") ++ self.assertEqual(cm.exception.implicit_type, "container") + + def test_member_no_remote(self): + replica_xml = etree.fromstring( +@@ -1159,23 +1153,15 @@ class TestBundleReplicaStatusToDto(TestCase): + """ + ) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- assert_raise_library_error( +- lambda: status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ with self.assertRaises( ++ status.BundleReplicaMissingImplicitResourceError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type + ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE, +- bundle_id=bundle_id, +- replica_id="0", +- ) +- ], +- ) ++ self.assertEqual(cm.exception.resource_id, self.bundle_id) ++ self.assertEqual(cm.exception.replica_id, "0") ++ self.assertEqual(cm.exception.implicit_type, "remote") + + def test_member_same_id_as_container(self): + # xml taken from crm_mon output +@@ -1198,22 +1184,14 @@ class TestBundleReplicaStatusToDto(TestCase): + + """ + ) +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type +- ) +- self.assertTrue(result is None) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, +- bundle_id=bundle_id, +- bad_ids=["resource-bundle-podman-0"], +- ) +- ], +- ) ++ with self.assertRaises( ++ status.BundleSameIdAsImplicitResourceError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type ++ ) ++ self.assertEqual(cm.exception.bundle_id, self.bundle_id) ++ self.assertEqual(cm.exception.bad_ids, ["resource-bundle-podman-0"]) + + def test_member_same_id_as_remote(self): + # xml taken from crm_mon output +@@ -1233,22 +1211,14 @@ class TestBundleReplicaStatusToDto(TestCase): + + """ + ) +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type +- ) +- self.assertTrue(result is None) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, +- bundle_id=bundle_id, +- bad_ids=["resource-bundle-0"], +- ) +- ], +- ) ++ with self.assertRaises( ++ status.BundleSameIdAsImplicitResourceError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type ++ ) ++ self.assertEqual(cm.exception.bundle_id, self.bundle_id) ++ self.assertEqual(cm.exception.bad_ids, ["resource-bundle-0"]) + + def test_member_same_id_as_ip(self): + # xml taken from crm_mon output +@@ -1271,22 +1241,15 @@ class TestBundleReplicaStatusToDto(TestCase): + + """ + ) +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- +- result = status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type +- ) +- self.assertTrue(result is None) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, +- bundle_id=bundle_id, +- bad_ids=["resource-bundle-ip-192.168.122.250"], +- ) +- ], ++ with self.assertRaises( ++ status.BundleSameIdAsImplicitResourceError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type ++ ) ++ self.assertEqual(cm.exception.bundle_id, self.bundle_id) ++ self.assertEqual( ++ cm.exception.bad_ids, ["resource-bundle-ip-192.168.122.250"] + ) + + def test_too_many_members(self): +@@ -1312,42 +1275,27 @@ class TestBundleReplicaStatusToDto(TestCase): + """ + ) + +- bundle_id = "resource-bundle" +- bundle_type = "podman" +- assert_raise_library_error( +- lambda: status._replica_to_dto( +- self.report_processor, replica_xml, bundle_id, bundle_type ++ with self.assertRaises( ++ status.BundleReplicaInvalidMemberCountError ++ ) as cm: ++ status._replica_to_dto( ++ replica_xml, self.bundle_id, self.bundle_type + ) +- ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT, +- bundle_id=bundle_id, +- replica_id="0", +- ) +- ], +- ) ++ self.assertEqual(cm.exception.resource_id, self.bundle_id) ++ self.assertEqual(cm.exception.replica_id, "0") + + + class TestBundleStatusToDto(TestCase): + # pylint: disable=protected-access +- def setUp(self): +- self.report_processor = MockLibraryReportProcessor() +- + def test_no_member(self): + bundle_xml = etree.fromstring( + fixture_bundle_xml(replicas=[fixture_replica_xml()]) + ) + +- result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ result = status._bundle_to_dto(bundle_xml, False) + self.assertEqual( + result, fixture_bundle_dto(replicas=[fixture_replica_dto()]) + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_member(self): + bundle_xml = etree.fromstring( +@@ -1362,7 +1310,7 @@ class TestBundleStatusToDto(TestCase): + ] + ) + ) +- result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ result = status._bundle_to_dto(bundle_xml, False) + self.assertEqual( + result, + fixture_bundle_dto( +@@ -1376,9 +1324,6 @@ class TestBundleStatusToDto(TestCase): + ] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_multiple_replicas(self): + bundle_xml = etree.fromstring( +@@ -1401,7 +1346,7 @@ class TestBundleStatusToDto(TestCase): + ] + ) + ) +- result = status._bundle_to_dto(self.report_processor, bundle_xml, False) ++ result = status._bundle_to_dto(bundle_xml, False) + self.assertEqual( + result, + fixture_bundle_dto( +@@ -1423,9 +1368,6 @@ class TestBundleStatusToDto(TestCase): + ] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) + + def test_same_id_as_implicit(self): + bundle_xml = etree.fromstring( +@@ -1447,18 +1389,13 @@ class TestBundleStatusToDto(TestCase): + + """ + ) +- result = status._bundle_to_dto(self.report_processor, bundle_xml, False) +- self.assertTrue(result is None) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, +- bundle_id="resource-bundle", +- bad_ids=["resource-bundle-0"], +- ) +- ], +- ) ++ ++ with self.assertRaises( ++ status.BundleSameIdAsImplicitResourceError ++ ) as cm: ++ status._bundle_to_dto(bundle_xml, False) ++ self.assertEqual(cm.exception.bundle_id, "resource-bundle") ++ self.assertEqual(cm.exception.bad_ids, ["resource-bundle-0"]) + + def test_same_id_as_implicit_multiple_replicas(self): + bundle_xml = etree.fromstring( +@@ -1491,18 +1428,12 @@ class TestBundleStatusToDto(TestCase): + + """ + ) +- result = status._bundle_to_dto(self.report_processor, bundle_xml, False) +- self.assertTrue(result is None) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.warn( +- reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, +- bundle_id="resource-bundle", +- bad_ids=["resource-bundle-1"], +- ) +- ], +- ) ++ with self.assertRaises( ++ status.BundleSameIdAsImplicitResourceError ++ ) as cm: ++ status._bundle_to_dto(bundle_xml, False) ++ self.assertEqual(cm.exception.bundle_id, "resource-bundle") ++ self.assertEqual(cm.exception.bad_ids, ["resource-bundle-1"]) + + def test_replicas_different(self): + replicas = { +@@ -1522,8 +1453,6 @@ class TestBundleStatusToDto(TestCase): + } + for name, element in replicas.items(): + with self.subTest(value=name): +- self.setUp() +- + bundle_xml = etree.fromstring( + fixture_bundle_xml( + replicas=[ +@@ -1537,47 +1466,29 @@ class TestBundleStatusToDto(TestCase): + ) + ) + +- # pylint: disable=cell-var-from-loop +- assert_raise_library_error( +- lambda: status._bundle_to_dto( +- self.report_processor, bundle_xml +- ) +- ) +- +- assert_report_item_list_equal( +- self.report_processor.report_item_list, +- [ +- fixture.error( +- reports.codes.CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS, +- bundle_id="resource-bundle", +- ) +- ], +- ) ++ with self.assertRaises(status.BundleDifferentReplicas) as cm: ++ status._bundle_to_dto(bundle_xml) ++ self.assertEqual(cm.exception.resource_id, "resource-bundle") + + + class TestResourcesStatusToDto(TestCase): +- def setUp(self): +- self.report_processor = MockLibraryReportProcessor() +- + def test_empty_resources(self): + status_xml = etree.fromstring(fixture_crm_mon_xml([])) + +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + self.assertEqual(result, ResourcesStatusDto([])) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) ++ assert_report_item_list_equal(parser.get_warnings(), []) + + def test_single_primitive(self): + status_xml = etree.fromstring( + fixture_crm_mon_xml([fixture_primitive_xml()]) + ) + +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + self.assertEqual(result, ResourcesStatusDto([fixture_primitive_dto()])) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) ++ assert_report_item_list_equal(parser.get_warnings(), []) + + def test_single_group(self): + status_xml = etree.fromstring( +@@ -1586,16 +1497,15 @@ class TestResourcesStatusToDto(TestCase): + ) + ) + +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + self.assertEqual( + result, + ResourcesStatusDto( + [fixture_group_dto(members=[fixture_primitive_dto()])] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) ++ assert_report_item_list_equal(parser.get_warnings(), []) + + def test_single_clone(self): + status_xml = etree.fromstring( +@@ -1604,16 +1514,15 @@ class TestResourcesStatusToDto(TestCase): + ) + ) + +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + self.assertEqual( + result, + ResourcesStatusDto( + [fixture_clone_dto(instances=[fixture_primitive_dto()])] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) ++ assert_report_item_list_equal(parser.get_warnings(), []) + + def test_single_bundle(self): + status_xml = etree.fromstring( +@@ -1633,7 +1542,8 @@ class TestResourcesStatusToDto(TestCase): + ) + ) + +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + self.assertEqual( + result, + ResourcesStatusDto( +@@ -1651,9 +1561,7 @@ class TestResourcesStatusToDto(TestCase): + ] + ), + ) +- assert_report_item_list_equal( +- self.report_processor.report_item_list, [] +- ) ++ assert_report_item_list_equal(parser.get_warnings(), []) + + def test_all_resource_types(self): + status_xml = etree.fromstring( +@@ -1675,30 +1583,30 @@ class TestResourcesStatusToDto(TestCase): + ] + ) + ) +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + +- self.assertEqual(result.resources[0], fixture_primitive_dto()) +- self.assertEqual( +- result.resources[1], +- fixture_group_dto(members=[fixture_primitive_dto()]), +- ) + self.assertEqual( +- result.resources[2], +- fixture_clone_dto(instances=[fixture_primitive_dto()]), +- ) +- self.assertEqual( +- result.resources[3], +- fixture_bundle_dto( +- replicas=[ +- fixture_replica_dto( +- ip=True, +- member=fixture_primitive_dto( +- node_names=["resource-bundle-0"] +- ), +- ) ++ result, ++ ResourcesStatusDto( ++ [ ++ fixture_primitive_dto(), ++ fixture_group_dto(members=[fixture_primitive_dto()]), ++ fixture_clone_dto(instances=[fixture_primitive_dto()]), ++ fixture_bundle_dto( ++ replicas=[ ++ fixture_replica_dto( ++ ip=True, ++ member=fixture_primitive_dto( ++ node_names=["resource-bundle-0"] ++ ), ++ ) ++ ] ++ ), + ] + ), + ) ++ assert_report_item_list_equal(parser.get_warnings(), []) + + def test_skip_bundle(self): + status_xml = etree.fromstring( +@@ -1726,11 +1634,12 @@ class TestResourcesStatusToDto(TestCase): + ) + ) + +- result = status.status_xml_to_dto(self.report_processor, status_xml) ++ parser = status.ClusterStatusParser(status_xml) ++ result = parser.status_xml_to_dto() + + self.assertEqual(result, ResourcesStatusDto([fixture_primitive_dto()])) + assert_report_item_list_equal( +- self.report_processor.report_item_list, ++ parser.get_warnings(), + [ + fixture.warn( + reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT, +-- +2.25.1 + diff --git a/increase-a-timeout-in-a-test.patch b/increase-a-timeout-in-a-test.patch new file mode 100644 index 0000000..9190617 --- /dev/null +++ b/increase-a-timeout-in-a-test.patch @@ -0,0 +1,28 @@ +From 78cfa76f7edbe362c152d2ad4ad8e4012a61e437 Mon Sep 17 00:00:00 2001 +From: Tomas Jelinek +Date: Wed, 17 Apr 2024 17:25:04 +0200 +Subject: [PATCH] increase a timeout in a test + +--- + pcs_test/tier0/daemon/app/test_app_remote.py | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/pcs_test/tier0/daemon/app/test_app_remote.py b/pcs_test/tier0/daemon/app/test_app_remote.py +index c6a6b235..dc176846 100644 +--- a/pcs_test/tier0/daemon/app/test_app_remote.py ++++ b/pcs_test/tier0/daemon/app/test_app_remote.py +@@ -121,7 +121,10 @@ class SyncConfigMutualExclusive(AppTest): + # Without lock the timeout should be enough to finish task. With the + # lock it should raise because of timeout. The same timeout is used for + # noticing differences between test with and test without lock. +- return self.io_loop.run_sync(fetch_sync_options, timeout=0.5) ++ # The timeout needs to be long enough for the test to fit into it even ++ # if running on a slower machine. And it should be short enough not to ++ # make the test run unnecessary long. ++ return self.io_loop.run_sync(fetch_sync_options, timeout=2.5) + + def check_call_wrapper_without_lock(self, method): + self.assert_wrappers_response(self.fetch_set_sync_options(method)) +-- +2.33.0 + diff --git a/pcs.spec b/pcs.spec index b323423..07c4225 100644 --- a/pcs.spec +++ b/pcs.spec @@ -1,6 +1,6 @@ Name: pcs Version: 0.11.7 -Release: 5 +Release: 15 License: GPL-2.0-only AND Apache-2.0 AND MIT AND BSD-3-Clause AND (BSD-2-Clause OR Ruby) AND (BSD-2-Clause OR GPL-2.0-or-later) URL: https://github.com/ClusterLabs/pcs Group: System Environment/Base @@ -41,12 +41,17 @@ Patch1: Support-for-openEuler.patch Patch2: fix-do-not-put-empty-uid-gid-options-to-an-uidgid-fi.patch Patch3: fix-stonith-level-validation.patch Patch4: Fix-pcsd-ruby.patch - +Patch5: update-crm_mon-schemas-for-tests.patch +Patch6: add-dtos-and-converting-functions-for-resources-stat.patch +Patch7: fixes-after-review.patch +Patch8: store-clone-instance-id-in-resource-status-dtos.patch +Patch9: increase-a-timeout-in-a-test.patch +Patch10: Export-rule-constraints-in-a-non-deprecated-way.patch +Patch11: backport-fix-stdout-wrapping-to-terminal-width.patch +Patch12: backport-fix-booth-destroy-for-arbitrators.patch # ui patches: >200 # Patch201: bzNUMBER-01-name.patch -# git for patches -BuildRequires: git-core BuildRequires: make # printf from coreutils is used in makefile, head is used in spec BuildRequires: coreutils @@ -182,6 +187,9 @@ Provides: bundled(pyagentx) = %{pyagentx_version} SNMP agent that provides information about pacemaker cluster to the master agent (snmpd) %prep +%if "%{_vendor}" != "openEuler" +sed -i 's/openEuler/%{_vendor}/g' %{PATCH1} +%endif # -- following is inspired by python-simplejon.el5 -- # Update timestamps on the files touched by a patch, to avoid non-equal @@ -226,16 +234,15 @@ update_times_patch(){ # * http://ftp.rpm.org/max-rpm/s1-rpm-inside-macros.html # * https://rpm-software-management.github.io/rpm/manual/autosetup.html # patch web-ui sources -%autosetup -D -T -b 3 -a 4 -S git -n %{ui_src_name} -N +%autosetup -D -T -b 3 -a 4 -n %{ui_src_name} -N %autopatch -p1 -m 201 # update_times_patch %%{PATCH201} # patch pcs sources -%autosetup -S git -n %{pcs_source_name} -N +%autosetup -n %{pcs_source_name} -N %autopatch -p1 -m 0 # update_times_patch %%{PATCH0} update_times_patch %{PATCH0} -sed -i "s/setuptools-scm/setuptools_scm/g" configure.ac # generate .tarball-version if building from an untagged commit, not a released version # autogen uses git-version-gen which uses .tarball-version for generating version number @@ -402,6 +409,36 @@ run_all_tests %license pyagentx_LICENSE.txt %changelog +* Mon Sep 02 2024 zouzhimin - 0.11.7-15 +- fix booth destroy for arbitrators + +* Wed May 29 2024 zouzhimin - 0.11.7-14 +- fix stdout wrapping to terminal width and modify spec file, sed command to replace "patch3" with "patch1" + +* Mon May 13 2024 zouzhimin - 0.11.7-13 +- fix: Support for other distributions and delete -S git from %autosetup + +* Sat May 11 2024 bixiaoyan - 0.11.7-12 +- export rule constraints in a non-deprecated way + +* Wed Apr 24 2024 bizhiyuan - 0.11.7-11 +- increase a timeout in a test + +* Mon Apr 22 2024 laokz - 0.11.7-10 +- restore setuptools-scm name to adapt setuptools-68.0.0 + +* Tue Mar 26 2024 zouzhimin - 0.11.7-9 +- Add dtos for resources status + +* Mon Mar 25 2024 zouzhimin - 0.11.7-8 +- fixes after review + +* Fri Mar 22 2024 zouzhimin - 0.11.7-7 +- add dtos and converting functions for resources status + +* Tue Mar 19 2024 zouzhimin - 0.11.7-6 +- update crm_mon schemas for tests + * Tue Mar 19 2024 panchenbo - 0.11.7-5 - fix setuptools_scm not found diff --git a/store-clone-instance-id-in-resource-status-dtos.patch b/store-clone-instance-id-in-resource-status-dtos.patch new file mode 100644 index 0000000..9c1b658 --- /dev/null +++ b/store-clone-instance-id-in-resource-status-dtos.patch @@ -0,0 +1,812 @@ +From 7c56001aa76c4a5f69f29b328061c419c7ce856b Mon Sep 17 00:00:00 2001 +From: Peter Romancik +Date: Thu, 1 Feb 2024 17:17:40 +0100 +Subject: [PATCH 1/2] further fixes after review + +--- + pcs/common/reports/codes.py | 2 +- + pcs/common/reports/messages.py | 8 +- + pcs/lib/pacemaker/status.py | 85 ++++++++++--------- + .../tier0/common/reports/test_messages.py | 14 +-- + pcs_test/tier0/lib/commands/test_status.py | 4 +- + pcs_test/tier0/lib/pacemaker/test_status.py | 68 ++++++++------- + 6 files changed, 97 insertions(+), 84 deletions(-) + +diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py +index f9614331..e967d0b1 100644 +--- a/pcs/common/reports/codes.py ++++ b/pcs/common/reports/codes.py +@@ -50,7 +50,7 @@ AGENT_SELF_VALIDATION_SKIPPED_UPDATED_RESOURCE_MISCONFIGURED = M( + ) + AGENT_SELF_VALIDATION_RESULT = M("AGENT_SELF_VALIDATION_RESULT") + BAD_CLUSTER_STATE_FORMAT = M("BAD_CLUSTER_STATE_FORMAT") +-BAD_CLUSTER_STATE = M("BAD_CLUSTER_STATE") ++BAD_CLUSTER_STATE_DATA = M("BAD_CLUSTER_STATE_DATA") + BOOTH_ADDRESS_DUPLICATION = M("BOOTH_ADDRESS_DUPLICATION") + BOOTH_ALREADY_IN_CIB = M("BOOTH_ALREADY_IN_CIB") + BOOTH_AUTHFILE_NOT_USED = M("BOOTH_AUTHFILE_NOT_USED") +diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py +index 8b9bc63e..53f15170 100644 +--- a/pcs/common/reports/messages.py ++++ b/pcs/common/reports/messages.py +@@ -3277,7 +3277,7 @@ class BadClusterStateFormat(ReportItemMessage): + + + @dataclass(frozen=True) +-class BadClusterState(ReportItemMessage): ++class BadClusterStateData(ReportItemMessage): + """ + crm_mon xml output is invalid despite conforming to the schema + +@@ -3285,13 +3285,13 @@ class BadClusterState(ReportItemMessage): + """ + + reason: Optional[str] = None +- _code = codes.BAD_CLUSTER_STATE ++ _code = codes.BAD_CLUSTER_STATE_DATA + + @property + def message(self) -> str: + return ( + "Cannot load cluster status, xml does not describe valid cluster " +- f"status{format_optional(self.reason, template=': {}')}." ++ f"status{format_optional(self.reason, template=': {}')}" + ) + + +@@ -3314,7 +3314,7 @@ class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage): + return ( + "Skipping bundle '{bundle_id}': {resource_word} " + "{bad_ids} {has} the same id as some of the " +- "implicit bundle resources." ++ "implicit bundle resources" + ).format( + bundle_id=self.bundle_id, + resource_word=format_plural(self.bad_ids, "resource"), +diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py +index a86ede55..deb8aa0d 100644 +--- a/pcs/lib/pacemaker/status.py ++++ b/pcs/lib/pacemaker/status.py +@@ -2,7 +2,6 @@ from collections import Counter + from typing import ( + Optional, + Sequence, +- Union, + cast, + ) + +@@ -60,11 +59,13 @@ class UnexpectedMemberError(ClusterStatusParsingError): + resource_id: str, + resource_type: str, + member_id: str, ++ member_type: str, + expected_types: list[str], + ): + super().__init__(resource_id) + self.resource_type = resource_type + self.member_id = member_id ++ self.member_type = member_type + self.expected_types = expected_types + + +@@ -106,46 +107,44 @@ def cluster_status_parsing_error_to_report( + ) -> reports.ReportItem: + reason = "" + if isinstance(e, EmptyResourceIdError): +- reason = "Resource with empty id." ++ reason = "Resource with an empty id" + elif isinstance(e, EmptyNodeNameError): + reason = ( +- f"Resource with id '{e.resource_id}' contains node with empty name." ++ f"Resource '{e.resource_id}' contains a node with an empty name" + ) + elif isinstance(e, UnknownPcmkRoleError): + reason = ( +- f"Resource with id '{e.resource_id}' contains unknown " +- f"pcmk role '{e.role}'." ++ f"Resource '{e.resource_id}' contains an unknown " ++ f"role '{e.role}'" + ) + elif isinstance(e, UnexpectedMemberError): + reason = ( +- f"Unexpected resource '{e.member_id}' inside of resource " +- f"'{e.resource_id}' of type '{e.resource_type}'. " +- f"Only resources of type {format_list(e.expected_types, '|')} " +- f"can be in {e.resource_type}." ++ f"Unexpected resource '{e.member_id}' of type '{e.member_type}' " ++ f"inside of resource '{e.resource_id}' of type '{e.resource_type}'." ++ f" Only resources of type {format_list(e.expected_types)} " ++ f"can be in a {e.resource_type}" + ) + + elif isinstance(e, MixedMembersError): +- reason = ( +- f"Primitive and group members mixed in clone '{e.resource_id}'." +- ) ++ reason = f"Primitive and group members mixed in clone '{e.resource_id}'" + elif isinstance(e, DifferentMemberIdsError): +- reason = f"Members with different ids in resource '{e.resource_id}'." ++ reason = f"Members with different ids in clone '{e.resource_id}'" + elif isinstance(e, BundleReplicaMissingImplicitResourceError): + reason = ( + f"Replica '{e.replica_id}' of bundle '{e.resource_id}' " +- f"is missing implicit {e.implicit_type} resource." ++ f"is missing implicit {e.implicit_type} resource" + ) + elif isinstance(e, BundleReplicaInvalidMemberCountError): + reason = ( + f"Replica '{e.replica_id}' of bundle '{e.resource_id}' has " +- "invalid number of members." ++ "invalid number of members" + ) + elif isinstance(e, BundleDifferentReplicas): +- reason = f"Replicas of bundle '{e.resource_id}' are not the same." ++ reason = f"Replicas of bundle '{e.resource_id}' are not the same" + + return reports.ReportItem( + reports.ReportItemSeverity.error(), +- reports.messages.BadClusterState(reason), ++ reports.messages.BadClusterStateData(reason), + ) + + +@@ -160,7 +159,7 @@ def _primitive_to_dto( + target_role = _get_target_role(primitive_el) + + node_names = [ +- str(node.get("name")) for node in primitive_el.iterfind("node") ++ str(node.attrib["name"]) for node in primitive_el.iterfind("node") + ] + + if node_names and any(not name for name in node_names): +@@ -168,7 +167,7 @@ def _primitive_to_dto( + + return PrimitiveStatusDto( + resource_id, +- str(primitive_el.get("resource_agent")), ++ str(primitive_el.attrib["resource_agent"]), + role, + target_role, + is_true(primitive_el.get("active", "false")), +@@ -179,7 +178,7 @@ def _primitive_to_dto( + is_true(primitive_el.get("failed", "false")), + is_true(primitive_el.get("managed", "false")), + is_true(primitive_el.get("failure_ignored", "false")), +- [str(node.get("name")) for node in primitive_el.iterfind("node")], ++ node_names, + primitive_el.get("pending"), + primitive_el.get("locked_to"), + ) +@@ -197,7 +196,11 @@ def _group_to_dto( + member_list.append(_primitive_to_dto(member, remove_clone_suffix)) + else: + raise UnexpectedMemberError( +- group_id, "group", str(member.get("id")), ["primitive"] ++ group_id, ++ "group", ++ str(member.attrib["id"]), ++ member.tag, ++ ["primitive"], + ) + + return GroupStatusDto( +@@ -228,29 +231,28 @@ def _clone_to_dto( + group_list.append(_group_to_dto(member, is_unique)) + else: + raise UnexpectedMemberError( +- clone_id, "clone", str(member.get("id")), ["primitive", "group"] ++ clone_id, ++ "clone", ++ str(member.attrib["id"]), ++ member.tag, ++ ["primitive", "group"], + ) + + if primitive_list and group_list: + raise MixedMembersError(clone_id) + +- instance_list: Union[list[PrimitiveStatusDto], list[GroupStatusDto]] + if primitive_list: + if len(set(res.resource_id for res in primitive_list)) > 1: + raise DifferentMemberIdsError(clone_id) +- instance_list = primitive_list +- else: ++ if group_list: + group_ids = set(group.resource_id for group in group_list) + children_ids = set( + tuple(child.resource_id for child in group.members) + for group in group_list + ) +- + if len(group_ids) > 1 or len(children_ids) > 1: + raise DifferentMemberIdsError(clone_id) + +- instance_list = group_list +- + return CloneStatusDto( + clone_id, + is_true(clone_el.get("multi_state", "false")), +@@ -262,7 +264,7 @@ def _clone_to_dto( + is_true(clone_el.get("failed", "false")), + is_true(clone_el.get("failure_ignored", "false")), + target_role, +- instance_list, ++ primitive_list or group_list, + ) + + +@@ -270,7 +272,7 @@ def _bundle_to_dto( + bundle_el: _Element, _remove_clone_suffix: bool = False + ) -> BundleStatusDto: + bundle_id = _get_resource_id(bundle_el) +- bundle_type = str(bundle_el.get("type")) ++ bundle_type = str(bundle_el.attrib["type"]) + + replica_list = [ + _replica_to_dto(replica, bundle_id, bundle_type) +@@ -283,7 +285,7 @@ def _bundle_to_dto( + return BundleStatusDto( + bundle_id, + bundle_type, +- str(bundle_el.get("image")), ++ str(bundle_el.attrib["image"]), + is_true(bundle_el.get("unique", "false")), + is_true(bundle_el.get("maintenance", "false")), + bundle_el.get("description"), +@@ -302,17 +304,18 @@ class ClusterStatusParser: + } + + def __init__(self, status: _Element): +- self.status = status +- self.warnings: reports.ReportItemList = [] ++ """ ++ status -- xml element from crm_mon xml, validated using the appropriate ++ rng schema ++ """ ++ self._status = status ++ self._warnings: reports.ReportItemList = [] + + def status_xml_to_dto(self) -> ResourcesStatusDto: + """ + Return dto containing status of configured resources in the cluster +- +- status -- status xml document from crm_mon, validated using +- the appropriate rng schema + """ +- resource_list = cast(list[_Element], self.status.xpath("resources/*")) ++ resource_list = cast(list[_Element], self._status.xpath("resources/*")) + + resource_dto_list = [] + for resource in resource_list: +@@ -328,7 +331,7 @@ class ClusterStatusParser: + # the implicitly created resource. + # We only skip such bundles while still providing status of the + # other resources. +- self.warnings.append( ++ self._warnings.append( + reports.ReportItem.warning( + reports.messages.ClusterStatusBundleMemberIdAsImplicit( + e.bundle_id, e.bad_ids +@@ -339,11 +342,11 @@ class ClusterStatusParser: + return ResourcesStatusDto(resource_dto_list) + + def get_warnings(self) -> reports.ReportItemList: +- return self.warnings ++ return self._warnings + + + def _get_resource_id(resource: _Element) -> str: +- resource_id = resource.get("id") ++ resource_id = resource.attrib["id"] + if not resource_id: + raise EmptyResourceIdError() + return str(resource_id) +@@ -374,7 +377,7 @@ def _remove_clone_suffix(resource_id: str) -> str: + def _replica_to_dto( + replica_el: _Element, bundle_id: str, bundle_type: str + ) -> BundleReplicaStatusDto: +- replica_id = str(replica_el.get("id")) ++ replica_id = str(replica_el.attrib["id"]) + + resource_list = [ + _primitive_to_dto(resource) +diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py +index 48eb730c..0ca95920 100644 +--- a/pcs_test/tier0/common/reports/test_messages.py ++++ b/pcs_test/tier0/common/reports/test_messages.py +@@ -2195,23 +2195,23 @@ class BadClusterStateFormat(NameBuildTest): + ) + + +-class BadClusterState(NameBuildTest): ++class BadClusterStateData(NameBuildTest): + def test_no_reason(self): + self.assert_message_from_report( + ( + "Cannot load cluster status, xml does not describe " +- "valid cluster status." ++ "valid cluster status" + ), +- reports.BadClusterState(), ++ reports.BadClusterStateData(), + ) + + def test_reason(self): + self.assert_message_from_report( + ( + "Cannot load cluster status, xml does not describe " +- "valid cluster status: sample reason." ++ "valid cluster status: sample reason" + ), +- reports.BadClusterState("sample reason"), ++ reports.BadClusterStateData("sample reason"), + ) + + +@@ -5843,7 +5843,7 @@ class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest): + self.assert_message_from_report( + ( + "Skipping bundle 'resource-bundle': resource 'resource' has " +- "the same id as some of the implicit bundle resources." ++ "the same id as some of the implicit bundle resources" + ), + reports.ClusterStatusBundleMemberIdAsImplicit( + "resource-bundle", ["resource"] +@@ -5855,7 +5855,7 @@ class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest): + ( + "Skipping bundle 'resource-bundle': resources 'resource-0', " + "'resource-1' have the same id as some of the implicit bundle " +- "resources." ++ "resources" + ), + reports.ClusterStatusBundleMemberIdAsImplicit( + "resource-bundle", ["resource-0", "resource-1"] +diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py +index 3b6b7665..b12e9531 100644 +--- a/pcs_test/tier0/lib/commands/test_status.py ++++ b/pcs_test/tier0/lib/commands/test_status.py +@@ -1342,8 +1342,8 @@ class ResourcesStatus(TestCase): + lambda: status.resources_status(self.env_assist.get_env()), + [ + fixture.error( +- report_codes.BAD_CLUSTER_STATE, +- reason="Resource with id 'R7' contains unknown pcmk role 'NotPcmkRole'.", ++ report_codes.BAD_CLUSTER_STATE_DATA, ++ reason="Resource 'R7' contains an unknown role 'NotPcmkRole'", + ), + ], + False, +diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py +index 778e97a6..ced1a47e 100644 +--- a/pcs_test/tier0/lib/pacemaker/test_status.py ++++ b/pcs_test/tier0/lib/pacemaker/test_status.py +@@ -12,6 +12,7 @@ from pcs.common import reports + from pcs.common.const import ( + PCMK_ROLE_STARTED, + PCMK_ROLES, ++ PCMK_STATUS_ROLE_PROMOTED, + PCMK_STATUS_ROLE_STARTED, + PCMK_STATUS_ROLE_STOPPED, + PCMK_STATUS_ROLE_UNPROMOTED, +@@ -334,8 +335,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Resource with empty id.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Resource with an empty id", + ), + ) + +@@ -346,8 +347,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Resource with id 'resource' contains node with empty name.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Resource 'resource' contains a node with an empty name", + ), + ) + +@@ -358,25 +359,25 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Resource with id 'resource' contains unknown pcmk role 'NotPcmkRole'.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Resource 'resource' contains an unknown role 'NotPcmkRole'", + ), + ) + + def test_unexpected_member_group(self): + report = status.cluster_status_parsing_error_to_report( + status.UnexpectedMemberError( +- "resource", "group", "member", ["primitive"] ++ "resource", "group", "member", "bundle", ["primitive"] + ) + ) + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, ++ reports.codes.BAD_CLUSTER_STATE_DATA, + reason=( +- "Unexpected resource 'member' inside of resource " +- "'resource' of type 'group'. Only resources of type " +- "'primitive' can be in group." ++ "Unexpected resource 'member' of type 'bundle' inside of " ++ "resource 'resource' of type 'group'. Only resources of " ++ "type 'primitive' can be in a group" + ), + ), + ) +@@ -384,17 +385,17 @@ class TestParsingErrorToReport(TestCase): + def test_unexpected_member_clone(self): + report = status.cluster_status_parsing_error_to_report( + status.UnexpectedMemberError( +- "resource", "clone", "member", ["primitive", "group"] ++ "resource", "clone", "member", "bundle", ["primitive", "group"] + ) + ) + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, ++ reports.codes.BAD_CLUSTER_STATE_DATA, + reason=( +- "Unexpected resource 'member' inside of resource " +- "'resource' of type 'clone'. Only resources of type " +- "'group'|'primitive' can be in clone." ++ "Unexpected resource 'member' of type 'bundle' inside of " ++ "resource 'resource' of type 'clone'. Only resources of " ++ "type 'group', 'primitive' can be in a clone" + ), + ), + ) +@@ -406,8 +407,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Primitive and group members mixed in clone 'resource'.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Primitive and group members mixed in clone 'resource'", + ), + ) + +@@ -418,8 +419,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Members with different ids in resource 'resource'.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Members with different ids in clone 'resource'", + ), + ) + +@@ -432,8 +433,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Replica '0' of bundle 'resource' is missing implicit container resource.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Replica '0' of bundle 'resource' is missing implicit container resource", + ), + ) + +@@ -444,8 +445,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Replica '0' of bundle 'resource' has invalid number of members.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Replica '0' of bundle 'resource' has invalid number of members", + ), + ) + +@@ -456,8 +457,8 @@ class TestParsingErrorToReport(TestCase): + assert_report_item_equal( + report, + fixture.error( +- reports.codes.BAD_CLUSTER_STATE, +- reason="Replicas of bundle 'resource' are not the same.", ++ reports.codes.BAD_CLUSTER_STATE_DATA, ++ reason="Replicas of bundle 'resource' are not the same", + ), + ) + +@@ -549,6 +550,7 @@ class TestPrimitiveStatusToDto(TestCase): + with self.assertRaises(status.UnknownPcmkRoleError) as cm: + status._primitive_to_dto(primitive_xml) + self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, "NotPcmkRole") + + def test_target_role(self): + for role in PCMK_ROLES: +@@ -573,6 +575,7 @@ class TestPrimitiveStatusToDto(TestCase): + with self.assertRaises(status.UnknownPcmkRoleError) as cm: + status._primitive_to_dto(primitive_xml) + self.assertEqual(cm.exception.resource_id, "resource") ++ self.assertEqual(cm.exception.role, value) + + + class TestGroupStatusToDto(TestCase): +@@ -695,7 +698,11 @@ class TestGroupStatusToDto(TestCase): + with self.assertRaises(status.UnexpectedMemberError) as cm: + status._group_to_dto(group_xml) + self.assertEqual(cm.exception.resource_id, "outer-group") ++ self.assertEqual(cm.exception.resource_type, "group") + self.assertEqual(cm.exception.member_id, resource_id) ++ self.assertEqual( ++ cm.exception.member_type, resource_id.split("-")[1] ++ ) + self.assertEqual(cm.exception.expected_types, ["primitive"]) + + def test_remove_clone_suffix(self): +@@ -796,7 +803,7 @@ class TestCloneStatusToDto(TestCase): + fixture_clone_xml( + multi_state=True, + instances=[ +- fixture_primitive_xml(role=PCMK_STATUS_ROLE_UNPROMOTED), ++ fixture_primitive_xml(role=PCMK_STATUS_ROLE_PROMOTED), + fixture_primitive_xml( + role=PCMK_STATUS_ROLE_UNPROMOTED, node_names=["node2"] + ), +@@ -810,7 +817,7 @@ class TestCloneStatusToDto(TestCase): + fixture_clone_dto( + multi_state=True, + instances=[ +- fixture_primitive_dto(role=PCMK_STATUS_ROLE_UNPROMOTED), ++ fixture_primitive_dto(role=PCMK_STATUS_ROLE_PROMOTED), + fixture_primitive_dto( + role=PCMK_STATUS_ROLE_UNPROMOTED, node_names=["node2"] + ), +@@ -1003,9 +1010,12 @@ class TestCloneStatusToDto(TestCase): + + with self.assertRaises(status.UnexpectedMemberError) as cm: + status._clone_to_dto(clone_xml) +- + self.assertEqual(cm.exception.resource_id, "outer-clone") ++ self.assertEqual(cm.exception.resource_type, "clone") + self.assertEqual(cm.exception.member_id, resource_id) ++ self.assertEqual( ++ cm.exception.member_type, resource_id.split("-")[1] ++ ) + self.assertEqual( + cm.exception.expected_types, ["primitive", "group"] + ) +-- +2.25.1 + +From c32249a39ef262e3f2106eb8ca01b6efb8e74707 Mon Sep 17 00:00:00 2001 +From: Peter Romancik +Date: Thu, 1 Feb 2024 17:45:20 +0100 +Subject: [PATCH 2/2] store clone instance id in resource status dtos + +--- + pcs/common/status_dto.py | 2 ++ + pcs/lib/pacemaker/status.py | 19 +++++++---- + pcs_test/tier0/lib/commands/test_status.py | 3 ++ + pcs_test/tier0/lib/pacemaker/test_status.py | 36 ++++++++++++++++----- + 4 files changed, 46 insertions(+), 14 deletions(-) + +diff --git a/pcs/common/status_dto.py b/pcs/common/status_dto.py +index dcc94eca..240ff930 100644 +--- a/pcs/common/status_dto.py ++++ b/pcs/common/status_dto.py +@@ -16,6 +16,7 @@ from pcs.common.interface.dto import DataTransferObject + class PrimitiveStatusDto(DataTransferObject): + # pylint: disable=too-many-instance-attributes + resource_id: str ++ clone_instance_id: Optional[str] + resource_agent: str + role: PcmkStatusRoleType + target_role: Optional[PcmkRoleType] +@@ -35,6 +36,7 @@ class PrimitiveStatusDto(DataTransferObject): + @dataclass(frozen=True) + class GroupStatusDto(DataTransferObject): + resource_id: str ++ clone_instance_id: Optional[str] + maintenance: bool + description: Optional[str] + managed: bool +diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py +index deb8aa0d..6b37d6cb 100644 +--- a/pcs/lib/pacemaker/status.py ++++ b/pcs/lib/pacemaker/status.py +@@ -152,8 +152,9 @@ def _primitive_to_dto( + primitive_el: _Element, remove_clone_suffix: bool = False + ) -> PrimitiveStatusDto: + resource_id = _get_resource_id(primitive_el) ++ clone_suffix = None + if remove_clone_suffix: +- resource_id = _remove_clone_suffix(resource_id) ++ resource_id, clone_suffix = _remove_clone_suffix(resource_id) + + role = _get_role(primitive_el) + target_role = _get_target_role(primitive_el) +@@ -167,6 +168,7 @@ def _primitive_to_dto( + + return PrimitiveStatusDto( + resource_id, ++ clone_suffix, + str(primitive_el.attrib["resource_agent"]), + role, + target_role, +@@ -187,8 +189,11 @@ def _primitive_to_dto( + def _group_to_dto( + group_el: _Element, remove_clone_suffix: bool = False + ) -> GroupStatusDto: +- # clone suffix is added even when the clone is non unique +- group_id = _remove_clone_suffix(_get_resource_id(group_el)) ++ # clone instance id present even when the clone is non unique ++ group_id, clone_instance_id = _remove_clone_suffix( ++ _get_resource_id(group_el) ++ ) ++ + member_list = [] + + for member in group_el: +@@ -205,6 +210,7 @@ def _group_to_dto( + + return GroupStatusDto( + group_id, ++ clone_instance_id, + is_true(group_el.get("maintenance", "false")), + group_el.get("description"), + is_true(group_el.get("managed", "false")), +@@ -368,10 +374,11 @@ def _get_target_role(resource: _Element) -> Optional[PcmkRoleType]: + return PcmkRoleType(target_role) + + +-def _remove_clone_suffix(resource_id: str) -> str: ++def _remove_clone_suffix(resource_id: str) -> tuple[str, Optional[str]]: + if ":" in resource_id: +- return resource_id.rsplit(":", 1)[0] +- return resource_id ++ resource_id, clone_suffix = resource_id.rsplit(":", 1) ++ return resource_id, clone_suffix ++ return resource_id, None + + + def _replica_to_dto( +diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py +index b12e9531..c7c808a3 100644 +--- a/pcs_test/tier0/lib/commands/test_status.py ++++ b/pcs_test/tier0/lib/commands/test_status.py +@@ -1280,6 +1280,7 @@ def _fixture_primitive_resource_dto( + ) -> PrimitiveStatusDto: + return PrimitiveStatusDto( + resource_id=resource_id, ++ clone_instance_id=None, + resource_agent=resource_agent, + role=PCMK_STATUS_ROLE_STOPPED, + target_role=target_role, +@@ -1448,6 +1449,7 @@ class ResourcesStatus(TestCase): + ), + GroupStatusDto( + resource_id="G2", ++ clone_instance_id=None, + maintenance=False, + description=None, + managed=True, +@@ -1475,6 +1477,7 @@ class ResourcesStatus(TestCase): + instances=[ + GroupStatusDto( + resource_id="G1", ++ clone_instance_id="0", + maintenance=False, + description=None, + managed=True, +diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py +index ced1a47e..a852d45b 100644 +--- a/pcs_test/tier0/lib/pacemaker/test_status.py ++++ b/pcs_test/tier0/lib/pacemaker/test_status.py +@@ -85,6 +85,7 @@ def fixture_primitive_xml( + + def fixture_primitive_dto( + resource_id: str = "resource", ++ clone_instance_id: Optional[str] = None, + resource_agent: str = "ocf:heartbeat:Dummy", + role: PcmkStatusRoleType = PCMK_STATUS_ROLE_STARTED, + target_role: Optional[str] = None, +@@ -94,6 +95,7 @@ def fixture_primitive_dto( + ) -> PrimitiveStatusDto: + return PrimitiveStatusDto( + resource_id, ++ clone_instance_id, + resource_agent, + role, + target_role, +@@ -136,11 +138,13 @@ def fixture_group_xml( + + def fixture_group_dto( + resource_id: str = "resource-group", ++ clone_instance_id: Optional[str] = None, + description: Optional[str] = None, + members: Sequence[PrimitiveStatusDto] = (), + ) -> GroupStatusDto: + return GroupStatusDto( + resource_id, ++ clone_instance_id, + maintenance=False, + description=description, + managed=True, +@@ -506,7 +510,7 @@ class TestPrimitiveStatusToDto(TestCase): + + result = status._primitive_to_dto(primitive_xml, True) + +- self.assertEqual(result, fixture_primitive_dto()) ++ self.assertEqual(result, fixture_primitive_dto(clone_instance_id="0")) + + def test_running_on_multiple_nodes(self): + primitive_xml = etree.fromstring( +@@ -716,7 +720,10 @@ class TestGroupStatusToDto(TestCase): + result = status._group_to_dto(group_xml, True) + self.assertEqual( + result, +- fixture_group_dto(members=[fixture_primitive_dto()]), ++ fixture_group_dto( ++ clone_instance_id="0", ++ members=[fixture_primitive_dto(clone_instance_id="0")], ++ ), + ) + + +@@ -792,8 +799,10 @@ class TestCloneStatusToDto(TestCase): + fixture_clone_dto( + unique=True, + instances=[ +- fixture_primitive_dto(), +- fixture_primitive_dto(node_names=["node2"]), ++ fixture_primitive_dto(clone_instance_id="0"), ++ fixture_primitive_dto( ++ clone_instance_id="1", node_names=["node2"] ++ ), + ], + ), + ) +@@ -886,9 +895,12 @@ class TestCloneStatusToDto(TestCase): + result, + fixture_clone_dto( + instances=[ +- fixture_group_dto(members=[fixture_primitive_dto()]), + fixture_group_dto( +- members=[fixture_primitive_dto(node_names=["node2"])] ++ clone_instance_id="0", members=[fixture_primitive_dto()] ++ ), ++ fixture_group_dto( ++ clone_instance_id="1", ++ members=[fixture_primitive_dto(node_names=["node2"])], + ), + ], + ), +@@ -923,9 +935,17 @@ class TestCloneStatusToDto(TestCase): + fixture_clone_dto( + unique=True, + instances=[ +- fixture_group_dto(members=[fixture_primitive_dto()]), + fixture_group_dto( +- members=[fixture_primitive_dto(node_names=["node2"])] ++ clone_instance_id="0", ++ members=[fixture_primitive_dto(clone_instance_id="0")], ++ ), ++ fixture_group_dto( ++ clone_instance_id="1", ++ members=[ ++ fixture_primitive_dto( ++ clone_instance_id="1", node_names=["node2"] ++ ) ++ ], + ), + ], + ), +-- +2.25.1 + diff --git a/update-crm_mon-schemas-for-tests.patch b/update-crm_mon-schemas-for-tests.patch new file mode 100644 index 0000000..92c937b --- /dev/null +++ b/update-crm_mon-schemas-for-tests.patch @@ -0,0 +1,957 @@ +From 2b7322113eec6a2d789d47d47049ae90c7a46625 Mon Sep 17 00:00:00 2001 +From: Peter Romancik +Date: Fri, 2 Feb 2024 10:50:11 +0100 +Subject: [PATCH] update crm_mon schemas for tests + +--- + pcs_test/Makefile.am | 11 +- + pcs_test/resources/crm_mon.minimal.xml | 4 +- + .../resources/pcmk_api_rng/api-result.rng | 2 +- + .../resources/pcmk_api_rng/crm_mon-2.29.rng | 213 ++++++++++++ + .../resources/pcmk_api_rng/crm_mon-2.4.rng | 311 ------------------ + .../resources/pcmk_api_rng/failure-2.8.rng | 33 ++ + ...nce-event-2.0.rng => fence-event-2.15.rng} | 3 + + .../resources/pcmk_api_rng/node-attrs-2.8.rng | 24 ++ + .../pcmk_api_rng/node-history-2.12.rng | 70 ++++ + .../resources/pcmk_api_rng/nodes-2.29.rng | 54 +++ + .../pcmk_api_rng/pacemakerd-health-2.25.rng | 20 ++ + .../{resources-2.4.rng => resources-2.29.rng} | 45 ++- + 12 files changed, 472 insertions(+), 318 deletions(-) + create mode 100644 pcs_test/resources/pcmk_api_rng/crm_mon-2.29.rng + delete mode 100644 pcs_test/resources/pcmk_api_rng/crm_mon-2.4.rng + create mode 100644 pcs_test/resources/pcmk_api_rng/failure-2.8.rng + rename pcs_test/resources/pcmk_api_rng/{fence-event-2.0.rng => fence-event-2.15.rng} (91%) + create mode 100644 pcs_test/resources/pcmk_api_rng/node-attrs-2.8.rng + create mode 100644 pcs_test/resources/pcmk_api_rng/node-history-2.12.rng + create mode 100644 pcs_test/resources/pcmk_api_rng/nodes-2.29.rng + create mode 100644 pcs_test/resources/pcmk_api_rng/pacemakerd-health-2.25.rng + rename pcs_test/resources/pcmk_api_rng/{resources-2.4.rng => resources-2.29.rng} (76%) + +diff --git a/pcs_test/Makefile.am b/pcs_test/Makefile.am +index 94e4c07a..32ac5eee 100644 +--- a/pcs_test/Makefile.am ++++ b/pcs_test/Makefile.am +@@ -35,10 +35,15 @@ EXTRA_DIST = \ + resources/fenced_metadata.xml \ + resources/schedulerd_metadata.xml \ + resources/pcmk_api_rng/api-result.rng \ +- resources/pcmk_api_rng/crm_mon-2.4.rng \ ++ resources/pcmk_api_rng/crm_mon-2.29.rng \ + resources/pcmk_api_rng/digests-2.9.rng \ +- resources/pcmk_api_rng/fence-event-2.0.rng \ +- resources/pcmk_api_rng/resources-2.4.rng \ ++ resources/pcmk_api_rng/failure-2.8.rng \ ++ resources/pcmk_api_rng/fence-event-2.15.rng \ ++ resources/pcmk_api_rng/node-attrs-2.8.rng \ ++ resources/pcmk_api_rng/node-history-2.12.rng \ ++ resources/pcmk_api_rng/nodes-2.29.rng \ ++ resources/pcmk_api_rng/pacemakerd-health-2.25.rng \ ++ resources/pcmk_api_rng/resources-2.29.rng \ + resources/pcmk_api_rng/status-2.0.rng \ + resources/resource_agent_ocf_heartbeat_dummy_insane_action.xml \ + resources/resource_agent_ocf_heartbeat_dummy_utf8.xml \ +diff --git a/pcs_test/resources/crm_mon.minimal.xml b/pcs_test/resources/crm_mon.minimal.xml +index 2c3473c0..df02fe3e 100644 +--- a/pcs_test/resources/crm_mon.minimal.xml ++++ b/pcs_test/resources/crm_mon.minimal.xml +@@ -1,4 +1,4 @@ +- ++ + + + +@@ -6,7 +6,7 @@ + + + +- ++ + + + +diff --git a/pcs_test/resources/pcmk_api_rng/api-result.rng b/pcs_test/resources/pcmk_api_rng/api-result.rng +index fcd812ac..e01cfdcd 100644 +--- a/pcs_test/resources/pcmk_api_rng/api-result.rng ++++ b/pcs_test/resources/pcmk_api_rng/api-result.rng +@@ -6,7 +6,7 @@ + + + +- ++ + + + +diff --git a/pcs_test/resources/pcmk_api_rng/crm_mon-2.29.rng b/pcs_test/resources/pcmk_api_rng/crm_mon-2.29.rng +new file mode 100644 +index 00000000..9cc554cf +--- /dev/null ++++ b/pcs_test/resources/pcmk_api_rng/crm_mon-2.29.rng +@@ -0,0 +1,213 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ granted ++ revoked ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/pcmk_api_rng/crm_mon-2.4.rng b/pcs_test/resources/pcmk_api_rng/crm_mon-2.4.rng +deleted file mode 100644 +index 88973a4e..00000000 +--- a/pcs_test/resources/pcmk_api_rng/crm_mon-2.4.rng ++++ /dev/null +@@ -1,311 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- unknown +- member +- remote +- ping +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- granted +- revoked +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/pcs_test/resources/pcmk_api_rng/failure-2.8.rng b/pcs_test/resources/pcmk_api_rng/failure-2.8.rng +new file mode 100644 +index 00000000..a36d2ea9 +--- /dev/null ++++ b/pcs_test/resources/pcmk_api_rng/failure-2.8.rng +@@ -0,0 +1,33 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/pcmk_api_rng/fence-event-2.0.rng b/pcs_test/resources/pcmk_api_rng/fence-event-2.15.rng +similarity index 91% +rename from pcs_test/resources/pcmk_api_rng/fence-event-2.0.rng +rename to pcs_test/resources/pcmk_api_rng/fence-event-2.15.rng +index e54687cd..8e000caf 100644 +--- a/pcs_test/resources/pcmk_api_rng/fence-event-2.0.rng ++++ b/pcs_test/resources/pcmk_api_rng/fence-event-2.15.rng +@@ -18,6 +18,9 @@ + + + ++ ++ ++ + + + +diff --git a/pcs_test/resources/pcmk_api_rng/node-attrs-2.8.rng b/pcs_test/resources/pcmk_api_rng/node-attrs-2.8.rng +new file mode 100644 +index 00000000..754ddb9e +--- /dev/null ++++ b/pcs_test/resources/pcmk_api_rng/node-attrs-2.8.rng +@@ -0,0 +1,24 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/pcmk_api_rng/node-history-2.12.rng b/pcs_test/resources/pcmk_api_rng/node-history-2.12.rng +new file mode 100644 +index 00000000..9628000e +--- /dev/null ++++ b/pcs_test/resources/pcmk_api_rng/node-history-2.12.rng +@@ -0,0 +1,70 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/pcmk_api_rng/nodes-2.29.rng b/pcs_test/resources/pcmk_api_rng/nodes-2.29.rng +new file mode 100644 +index 00000000..7dd17989 +--- /dev/null ++++ b/pcs_test/resources/pcmk_api_rng/nodes-2.29.rng +@@ -0,0 +1,54 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ red ++ yellow ++ green ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ unknown ++ member ++ remote ++ ping ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/pcmk_api_rng/pacemakerd-health-2.25.rng b/pcs_test/resources/pcmk_api_rng/pacemakerd-health-2.25.rng +new file mode 100644 +index 00000000..2089b25f +--- /dev/null ++++ b/pcs_test/resources/pcmk_api_rng/pacemakerd-health-2.25.rng +@@ -0,0 +1,20 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/pcs_test/resources/pcmk_api_rng/resources-2.4.rng b/pcs_test/resources/pcmk_api_rng/resources-2.29.rng +similarity index 76% +rename from pcs_test/resources/pcmk_api_rng/resources-2.4.rng +rename to pcs_test/resources/pcmk_api_rng/resources-2.29.rng +index e2795836..f4214a7c 100644 +--- a/pcs_test/resources/pcmk_api_rng/resources-2.4.rng ++++ b/pcs_test/resources/pcmk_api_rng/resources-2.29.rng +@@ -35,6 +35,16 @@ + + + ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +@@ -53,6 +63,16 @@ + + + ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +@@ -68,6 +88,16 @@ + + + ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +@@ -87,13 +117,26 @@ + + + +- ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + ++ + + + + + ++ ++ ++ + + + +-- +2.25.1 +