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/pcs.spec b/pcs.spec index e04b6e4..d8291b2 100644 --- a/pcs.spec +++ b/pcs.spec @@ -1,6 +1,6 @@ Name: pcs Version: 0.11.7 -Release: 6 +Release: 7 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 @@ -42,6 +42,7 @@ 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 # ui patches: >200 # Patch201: bzNUMBER-01-name.patch @@ -403,6 +404,9 @@ run_all_tests %license pyagentx_LICENSE.txt %changelog +* 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