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