From 75a8e52584a71087ae734c427870c9ea8d7935c7 Mon Sep 17 00:00:00 2001
From: Peter Romancik <promanci@redhat.com>
Date: Tue, 30 Jan 2024 18:02:50 +0100
Subject: [PATCH] fixes after review

---
 pcs/common/const.py                           |   2 -
 pcs/common/reports/codes.py                   |  20 +-
 pcs/common/reports/messages.py                | 187 +---
 pcs/lib/commands/status.py                    |  16 +-
 pcs/lib/pacemaker/status.py                   | 527 ++++++-----
 pcs_test/resources/crm_mon.all_resources.xml  |  75 +-
 .../tier0/common/reports/test_messages.py     | 155 +---
 pcs_test/tier0/lib/commands/test_status.py    | 303 ++++--
 pcs_test/tier0/lib/pacemaker/test_status.py   | 865 ++++++++----------
 9 files changed, 968 insertions(+), 1182 deletions(-)

diff --git a/pcs/common/const.py b/pcs/common/const.py
index 32175677..00d1b7e7 100644
--- a/pcs/common/const.py
+++ b/pcs/common/const.py
@@ -14,14 +14,12 @@ PCMK_ROLE_PROMOTED = PcmkRoleType("Promoted")
 PCMK_ROLE_UNPROMOTED = PcmkRoleType("Unpromoted")
 PCMK_ROLE_PROMOTED_LEGACY = PcmkRoleType("Master")
 PCMK_ROLE_UNPROMOTED_LEGACY = PcmkRoleType("Slave")
-PCMK_ROLE_UNKNOWN = PcmkRoleType("Unknown")
 PCMK_STATUS_ROLE_STARTED = PcmkStatusRoleType("Started")
 PCMK_STATUS_ROLE_STOPPED = PcmkStatusRoleType("Stopped")
 PCMK_STATUS_ROLE_PROMOTED = PcmkStatusRoleType("Promoted")
 PCMK_STATUS_ROLE_UNPROMOTED = PcmkStatusRoleType("Unpromoted")
 PCMK_STATUS_ROLE_STARTING = PcmkStatusRoleType("Starting")
 PCMK_STATUS_ROLE_STOPPING = PcmkStatusRoleType("Stopping")
-PCMK_STATUS_ROLE_UNKNOWN = PcmkStatusRoleType("Unknown")
 PCMK_ON_FAIL_ACTION_IGNORE = PcmkOnFailAction("ignore")
 PCMK_ON_FAIL_ACTION_BLOCK = PcmkOnFailAction("block")
 PCMK_ON_FAIL_ACTION_DEMOTE = PcmkOnFailAction("demote")
diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py
index 417a3f4a..f9614331 100644
--- a/pcs/common/reports/codes.py
+++ b/pcs/common/reports/codes.py
@@ -50,6 +50,7 @@ AGENT_SELF_VALIDATION_SKIPPED_UPDATED_RESOURCE_MISCONFIGURED = M(
 )
 AGENT_SELF_VALIDATION_RESULT = M("AGENT_SELF_VALIDATION_RESULT")
 BAD_CLUSTER_STATE_FORMAT = M("BAD_CLUSTER_STATE_FORMAT")
+BAD_CLUSTER_STATE = M("BAD_CLUSTER_STATE")
 BOOTH_ADDRESS_DUPLICATION = M("BOOTH_ADDRESS_DUPLICATION")
 BOOTH_ALREADY_IN_CIB = M("BOOTH_ALREADY_IN_CIB")
 BOOTH_AUTHFILE_NOT_USED = M("BOOTH_AUTHFILE_NOT_USED")
@@ -156,28 +157,9 @@ CLUSTER_RESTART_REQUIRED_TO_APPLY_CHANGES = M(
 CLUSTER_SETUP_SUCCESS = M("CLUSTER_SETUP_SUCCESS")
 CLUSTER_START_STARTED = M("CLUSTER_START_STARTED")
 CLUSTER_START_SUCCESS = M("CLUSTER_START_SUCCESS")
-CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS = M(
-    "CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS"
-)
 CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT = M(
     "CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT"
 )
-CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT = M(
-    "CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT"
-)
-CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE = M(
-    "CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE"
-)
-CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER = M(
-    "CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER"
-)
-CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS = M(
-    "CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS"
-)
-CLUSTER_STATUS_CLONE_MIXED_MEMBERS = M("CLUSTER_STATUS_CLONE_MIXED_MEMBERS")
-CLUSTER_STATUS_EMPTY_NODE_NAME = M("CLUSTER_STATUS_EMPTY_NODE_NAME")
-CLUSTER_STATUS_UNEXPECTED_MEMBER = M("CLUSTER_STATUS_UNEXPECTED_MEMBER")
-CLUSTER_STATUS_UNKNOWN_PCMK_ROLE = M("CLUSTER_STATUS_UNKNOWN_PCMK_ROLE")
 CLUSTER_UUID_ALREADY_SET = M("CLUSTER_UUID_ALREADY_SET")
 CLUSTER_WILL_BE_DESTROYED = M("CLUSTER_WILL_BE_DESTROYED")
 COMMAND_INVALID_PAYLOAD = M("COMMAND_INVALID_PAYLOAD")
diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py
index 1e98711c..8b9bc63e 100644
--- a/pcs/common/reports/messages.py
+++ b/pcs/common/reports/messages.py
@@ -3277,183 +3277,32 @@ class BadClusterStateFormat(ReportItemMessage):
 
 
 @dataclass(frozen=True)
-class ClusterStatusUnknownPcmkRole(ReportItemMessage):
+class BadClusterState(ReportItemMessage):
     """
-    Value of pcmk role in the status xml is not valid
+    crm_mon xml output is invalid despite conforming to the schema
 
-    role -- value of the role attribute
-    resource_id -- id of the resource
-    """
-
-    role: Optional[str]
-    resource_id: str
-    _code = codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE
-
-    @property
-    def message(self) -> str:
-        return (
-            "Attribute of resource with id '{id}' "
-            "contains {invalid} pcmk role{role}."
-        ).format(
-            id=self.resource_id,
-            invalid="empty" if not self.role else "invalid",
-            role=f" '{self.role}'" if self.role else "",
-        )
-
-
-@dataclass(frozen=True)
-class ClusterStatusEmptyNodeName(ReportItemMessage):
-    """
-    Resource in the status xml contains node with empty name
-
-    resource_id -- id of the resource
-    """
-
-    resource_id: str
-    _code = codes.CLUSTER_STATUS_EMPTY_NODE_NAME
-
-    @property
-    def message(self) -> str:
-        return (
-            f"Resource with id '{self.resource_id}' contains node "
-            "with empty name."
-        )
-
-
-@dataclass(frozen=True)
-class ClusterStatusUnexpectedMember(ReportItemMessage):
-    """
-    Unexpected resource type is present in present as child element
-    in another resource type
-
-    resource_id -- id of the outer resource
-    resource_type -- type of the outer resource
-    member_id -- id of the unexpected member
-    expected_type -- valid types for members
-    """
-
-    resource_id: str
-    resource_type: str
-    member_id: str
-    expected_types: list[str]
-    _code = codes.CLUSTER_STATUS_UNEXPECTED_MEMBER
-
-    @property
-    def message(self) -> str:
-        return (
-            f"Unexpected resource '{self.member_id}' inside of resource "
-            f"'{self.resource_id}' of type '{self.resource_type}'. "
-            f"Only resources of type {format_list(self.expected_types, '|')} "
-            f"can be in {self.resource_type}."
-        )
-
-
-@dataclass(frozen=True)
-class ClusterStatusCloneMixedMembers(ReportItemMessage):
-    """
-    Members of multiple types are present in a clone in the status xml
-
-    member_id -- id of the unexpected member
-    clone_id -- id of the clone
-    """
-
-    clone_id: str
-    _code = codes.CLUSTER_STATUS_CLONE_MIXED_MEMBERS
-
-    @property
-    def message(self) -> str:
-        return f"Primitive and group members mixed in clone '{self.clone_id}'."
-
-
-@dataclass(frozen=True)
-class ClusterStatusCloneMembersDifferentIds(ReportItemMessage):
-    """
-    Clone instances in crm_mon status xml have different ids
-
-    clone_id -- id of the clone
-    """
-
-    clone_id: str
-    _code = codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS
-
-    @property
-    def message(self) -> str:
-        return f"Members with different ids in clone '{self.clone_id}'."
-
-
-@dataclass(frozen=True)
-class ClusterStatusBundleReplicaNoContainer(ReportItemMessage):
-    """
-    Bundle replica is missing implicit container resource in the status xml
-
-    bundle_id -- id of the bundle
-    replica_id -- id of the replica
-    """
-
-    bundle_id: str
-    replica_id: str
-    _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER
-
-    @property
-    def message(self) -> str:
-        return (
-            f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' "
-            "is missing implicit container resource."
-        )
-
-
-@dataclass(frozen=True)
-class ClusterStatusBundleReplicaMissingRemote(ReportItemMessage):
-    """
-    Bundle replica is missing implicit pacemaker remote resource
-    in the status xml
-
-    bundle_id -- id of the bundle
-    replica_id -- id of the replica
-    """
-
-    bundle_id: str
-    replica_id: str
-    _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE
-
-    @property
-    def message(self) -> str:
-        return (
-            f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' is "
-            "missing implicit pacemaker remote resource while it must be "
-            "present."
-        )
-
-
-@dataclass(frozen=True)
-class ClusterStatusBundleReplicaInvalidCount(ReportItemMessage):
+    reason -- error description
     """
-    Bundle replica is has invalid number of members in the status xml
 
-    bundle_id -- id of the bundle
-    replica_id -- id of the replica
-    """
-
-    bundle_id: str
-    replica_id: str
-    _code = codes.CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT
+    reason: Optional[str] = None
+    _code = codes.BAD_CLUSTER_STATE
 
     @property
     def message(self) -> str:
         return (
-            f"Replica '{self.replica_id}' of bundle '{self.bundle_id}' has "
-            f"invalid number of members. Expecting 2-4 members."
+            "Cannot load cluster status, xml does not describe valid cluster "
+            f"status{format_optional(self.reason, template=': {}')}."
         )
 
 
 @dataclass(frozen=True)
 class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage):
     """
-    Member of bundle in cluster status xml has the same id as one of
-    the implicit resources
+    Member of bundle in cluster status xml has the same id as one of the
+    implicit resources
 
     bundle_id -- id of the bundle
-    member_id -- id if the bundle member
+    bad_ids -- ids of the bad members
     """
 
     bundle_id: str
@@ -3474,22 +3323,6 @@ class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage):
         )
 
 
-@dataclass(frozen=True)
-class ClusterStatusBundleDifferentReplicas(ReportItemMessage):
-    """
-    Replicas of bundle are different in the cluster status xml
-
-    bundle_id -- id of the bundle
-    """
-
-    bundle_id: str
-    _code = codes.CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS
-
-    @property
-    def message(self) -> str:
-        return f"Replicas of bundle '{self.bundle_id}' are not the same."
-
-
 @dataclass(frozen=True)
 class WaitForIdleStarted(ReportItemMessage):
     """
diff --git a/pcs/lib/commands/status.py b/pcs/lib/commands/status.py
index 8b644ac1..f9ec6160 100644
--- a/pcs/lib/commands/status.py
+++ b/pcs/lib/commands/status.py
@@ -49,7 +49,11 @@ from pcs.lib.pacemaker.live import (
     get_cluster_status_xml_raw,
     get_ticket_status_text,
 )
-from pcs.lib.pacemaker.status import status_xml_to_dto
+from pcs.lib.pacemaker.status import (
+    ClusterStatusParser,
+    ClusterStatusParsingError,
+    cluster_status_parsing_error_to_report,
+)
 from pcs.lib.resource_agent.const import STONITH_ACTION_REPLACED_BY
 from pcs.lib.sbd import get_sbd_service_name
 
@@ -79,7 +83,15 @@ def resources_status(env: LibraryEnvironment) -> ResourcesStatusDto:
     """
     status_xml = env.get_cluster_state()
 
-    return status_xml_to_dto(env.report_processor, status_xml)
+    parser = ClusterStatusParser(status_xml)
+    try:
+        dto = parser.status_xml_to_dto()
+    except ClusterStatusParsingError as e:
+        raise LibraryError(cluster_status_parsing_error_to_report(e)) from e
+
+    env.report_processor.report_list(parser.get_warnings())
+
+    return dto
 
 
 def full_cluster_status_plaintext(
diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py
index 722ce03f..a86ede55 100644
--- a/pcs/lib/pacemaker/status.py
+++ b/pcs/lib/pacemaker/status.py
@@ -1,3 +1,4 @@
+from collections import Counter
 from typing import (
     Optional,
     Sequence,
@@ -9,14 +10,11 @@ from lxml.etree import _Element
 
 from pcs.common import reports
 from pcs.common.const import (
-    PCMK_ROLE_UNKNOWN,
     PCMK_ROLES,
-    PCMK_STATUS_ROLE_UNKNOWN,
     PCMK_STATUS_ROLES,
     PcmkRoleType,
     PcmkStatusRoleType,
 )
-from pcs.common.reports import ReportProcessor
 from pcs.common.status_dto import (
     AnyResourceStatusDto,
     BundleReplicaStatusDto,
@@ -26,7 +24,7 @@ from pcs.common.status_dto import (
     PrimitiveStatusDto,
     ResourcesStatusDto,
 )
-from pcs.lib.errors import LibraryError
+from pcs.common.str_tools import format_list
 from pcs.lib.pacemaker.values import is_true
 
 _PRIMITIVE_TAG = "resource"
@@ -36,31 +34,137 @@ _BUNDLE_TAG = "bundle"
 _REPLICA_TAG = "replica"
 
 
+class ClusterStatusParsingError(Exception):
+    def __init__(self, resource_id: str):
+        self.resource_id = resource_id
+
+
+class EmptyResourceIdError(ClusterStatusParsingError):
+    def __init__(self):
+        super().__init__("")
+
+
+class EmptyNodeNameError(ClusterStatusParsingError):
+    pass
+
+
+class UnknownPcmkRoleError(ClusterStatusParsingError):
+    def __init__(self, resource_id: str, role: str):
+        super().__init__(resource_id)
+        self.role = role
+
+
+class UnexpectedMemberError(ClusterStatusParsingError):
+    def __init__(
+        self,
+        resource_id: str,
+        resource_type: str,
+        member_id: str,
+        expected_types: list[str],
+    ):
+        super().__init__(resource_id)
+        self.resource_type = resource_type
+        self.member_id = member_id
+        self.expected_types = expected_types
+
+
+class MixedMembersError(ClusterStatusParsingError):
+    pass
+
+
+class DifferentMemberIdsError(ClusterStatusParsingError):
+    pass
+
+
+class BundleReplicaMissingImplicitResourceError(ClusterStatusParsingError):
+    def __init__(
+        self, resource_id: str, replica_id: str, implicit_resource_type: str
+    ):
+        super().__init__(resource_id)
+        self.replica_id = replica_id
+        self.implicit_type = implicit_resource_type
+
+
+class BundleReplicaInvalidMemberCountError(ClusterStatusParsingError):
+    def __init__(self, resource_id: str, replica_id: str):
+        super().__init__(resource_id)
+        self.replica_id = replica_id
+
+
+class BundleDifferentReplicas(ClusterStatusParsingError):
+    pass
+
+
+class BundleSameIdAsImplicitResourceError(Exception):
+    def __init__(self, bundle_id: str, bad_ids: list[str]):
+        self.bundle_id = bundle_id
+        self.bad_ids = bad_ids
+
+
+def cluster_status_parsing_error_to_report(
+    e: ClusterStatusParsingError,
+) -> reports.ReportItem:
+    reason = ""
+    if isinstance(e, EmptyResourceIdError):
+        reason = "Resource with empty id."
+    elif isinstance(e, EmptyNodeNameError):
+        reason = (
+            f"Resource with id '{e.resource_id}' contains node with empty name."
+        )
+    elif isinstance(e, UnknownPcmkRoleError):
+        reason = (
+            f"Resource with id '{e.resource_id}' contains unknown "
+            f"pcmk role '{e.role}'."
+        )
+    elif isinstance(e, UnexpectedMemberError):
+        reason = (
+            f"Unexpected resource '{e.member_id}' inside of resource "
+            f"'{e.resource_id}' of type '{e.resource_type}'. "
+            f"Only resources of type {format_list(e.expected_types, '|')} "
+            f"can be in {e.resource_type}."
+        )
+
+    elif isinstance(e, MixedMembersError):
+        reason = (
+            f"Primitive and group members mixed in clone '{e.resource_id}'."
+        )
+    elif isinstance(e, DifferentMemberIdsError):
+        reason = f"Members with different ids in resource '{e.resource_id}'."
+    elif isinstance(e, BundleReplicaMissingImplicitResourceError):
+        reason = (
+            f"Replica '{e.replica_id}' of bundle '{e.resource_id}' "
+            f"is missing implicit {e.implicit_type} resource."
+        )
+    elif isinstance(e, BundleReplicaInvalidMemberCountError):
+        reason = (
+            f"Replica '{e.replica_id}' of bundle '{e.resource_id}' has "
+            "invalid number of members."
+        )
+    elif isinstance(e, BundleDifferentReplicas):
+        reason = f"Replicas of bundle '{e.resource_id}' are not the same."
+
+    return reports.ReportItem(
+        reports.ReportItemSeverity.error(),
+        reports.messages.BadClusterState(reason),
+    )
+
+
 def _primitive_to_dto(
-    reporter: ReportProcessor,
-    primitive_el: _Element,
-    remove_clone_suffix: bool = False,
+    primitive_el: _Element, remove_clone_suffix: bool = False
 ) -> PrimitiveStatusDto:
-    resource_id = _get_resource_id(reporter, primitive_el)
+    resource_id = _get_resource_id(primitive_el)
     if remove_clone_suffix:
         resource_id = _remove_clone_suffix(resource_id)
 
-    role = _get_role(reporter, primitive_el, resource_id)
-    target_role = _get_target_role(reporter, primitive_el, resource_id)
+    role = _get_role(primitive_el)
+    target_role = _get_target_role(primitive_el)
 
     node_names = [
         str(node.get("name")) for node in primitive_el.iterfind("node")
     ]
 
     if node_names and any(not name for name in node_names):
-        reporter.report(
-            reports.ReportItem.error(
-                reports.messages.ClusterStatusEmptyNodeName(resource_id)
-            )
-        )
-
-    if reporter.has_errors:
-        raise LibraryError()
+        raise EmptyNodeNameError(resource_id)
 
     return PrimitiveStatusDto(
         resource_id,
@@ -82,87 +186,70 @@ def _primitive_to_dto(
 
 
 def _group_to_dto(
-    reporter: ReportProcessor,
-    group_el: _Element,
-    remove_clone_suffix: bool = False,
+    group_el: _Element, remove_clone_suffix: bool = False
 ) -> GroupStatusDto:
     # clone suffix is added even when the clone is non unique
-    group_id = _remove_clone_suffix(_get_resource_id(reporter, group_el))
-    members = []
+    group_id = _remove_clone_suffix(_get_resource_id(group_el))
+    member_list = []
 
     for member in group_el:
         if member.tag == _PRIMITIVE_TAG:
-            members.append(
-                _primitive_to_dto(reporter, member, remove_clone_suffix)
-            )
+            member_list.append(_primitive_to_dto(member, remove_clone_suffix))
         else:
-            reporter.report(
-                reports.ReportItem.error(
-                    reports.messages.ClusterStatusUnexpectedMember(
-                        group_id, "group", str(member.get("id")), ["primitive"]
-                    )
-                )
+            raise UnexpectedMemberError(
+                group_id, "group", str(member.get("id")), ["primitive"]
             )
 
-    if reporter.has_errors:
-        raise LibraryError()
-
     return GroupStatusDto(
         group_id,
         is_true(group_el.get("maintenance", "false")),
         group_el.get("description"),
         is_true(group_el.get("managed", "false")),
         is_true(group_el.get("disabled", "false")),
-        members,
+        member_list,
     )
 
 
 def _clone_to_dto(
-    reporter: ReportProcessor,
-    clone_el: _Element,
-    _remove_clone_suffix: bool = False,
+    clone_el: _Element, _remove_clone_suffix: bool = False
 ) -> CloneStatusDto:
-    clone_id = _get_resource_id(reporter, clone_el)
+    clone_id = _get_resource_id(clone_el)
     is_unique = is_true(clone_el.get("unique", "false"))
 
-    target_role = _get_target_role(reporter, clone_el, clone_id)
+    target_role = _get_target_role(clone_el)
 
-    primitives = []
-    groups = []
+    primitive_list = []
+    group_list = []
 
     for member in clone_el:
         if member.tag == _PRIMITIVE_TAG:
-            primitives.append(_primitive_to_dto(reporter, member, is_unique))
+            primitive_list.append(_primitive_to_dto(member, is_unique))
         elif member.tag == _GROUP_TAG:
-            groups.append(_group_to_dto(reporter, member, is_unique))
+            group_list.append(_group_to_dto(member, is_unique))
         else:
-            reporter.report(
-                reports.ReportItem.error(
-                    reports.messages.ClusterStatusUnexpectedMember(
-                        clone_id,
-                        "clone",
-                        str(member.get("id")),
-                        ["primitive", "group"],
-                    )
-                )
+            raise UnexpectedMemberError(
+                clone_id, "clone", str(member.get("id")), ["primitive", "group"]
             )
 
-    reporter.report_list(
-        _validate_mixed_instance_types(primitives, groups, clone_id)
-    )
+    if primitive_list and group_list:
+        raise MixedMembersError(clone_id)
 
-    instances: Union[list[PrimitiveStatusDto], list[GroupStatusDto]]
-    if primitives:
-        reporter.report_list(
-            _validate_primitive_instance_ids(primitives, clone_id)
-        )
-        instances = primitives
+    instance_list: Union[list[PrimitiveStatusDto], list[GroupStatusDto]]
+    if primitive_list:
+        if len(set(res.resource_id for res in primitive_list)) > 1:
+            raise DifferentMemberIdsError(clone_id)
+        instance_list = primitive_list
     else:
-        reporter.report_list(_validate_group_instance_ids(groups, clone_id))
-        instances = groups
+        group_ids = set(group.resource_id for group in group_list)
+        children_ids = set(
+            tuple(child.resource_id for child in group.members)
+            for group in group_list
+        )
+
+        if len(group_ids) > 1 or len(children_ids) > 1:
+            raise DifferentMemberIdsError(clone_id)
 
-    if reporter.has_errors:
-        raise LibraryError()
+        instance_list = group_list
 
     return CloneStatusDto(
         clone_id,
@@ -175,30 +262,23 @@ def _clone_to_dto(
         is_true(clone_el.get("failed", "false")),
         is_true(clone_el.get("failure_ignored", "false")),
         target_role,
-        instances,
+        instance_list,
     )
 
 
 def _bundle_to_dto(
-    reporter: ReportProcessor,
-    bundle_el: _Element,
-    _remove_clone_suffix: bool = False,
-) -> Optional[BundleStatusDto]:
-    bundle_id = _get_resource_id(reporter, bundle_el)
+    bundle_el: _Element, _remove_clone_suffix: bool = False
+) -> BundleStatusDto:
+    bundle_id = _get_resource_id(bundle_el)
     bundle_type = str(bundle_el.get("type"))
 
-    replicas = []
-    for replica in bundle_el.iterfind(_REPLICA_TAG):
-        replica_dto = _replica_to_dto(reporter, replica, bundle_id, bundle_type)
-        if replica_dto is None:
-            # skip this bundle in status
-            return None
-        replicas.append(replica_dto)
-
-    reporter.report_list(_validate_replicas(replicas, bundle_id))
+    replica_list = [
+        _replica_to_dto(replica, bundle_id, bundle_type)
+        for replica in bundle_el.iterfind(_REPLICA_TAG)
+    ]
 
-    if reporter.has_errors:
-        raise LibraryError()
+    if not _replicas_valid(replica_list):
+        raise BundleDifferentReplicas(bundle_id)
 
     return BundleStatusDto(
         bundle_id,
@@ -209,87 +289,79 @@ def _bundle_to_dto(
         bundle_el.get("description"),
         is_true(bundle_el.get("managed", "false")),
         is_true(bundle_el.get("failed", "false")),
-        replicas,
+        replica_list,
     )
 
 
-_TAG_TO_FUNCTION = {
-    _PRIMITIVE_TAG: _primitive_to_dto,
-    _GROUP_TAG: _group_to_dto,
-    _CLONE_TAG: _clone_to_dto,
-    _BUNDLE_TAG: _bundle_to_dto,
-}
-
-
-def status_xml_to_dto(
-    reporter: ReportProcessor, status: _Element
-) -> ResourcesStatusDto:
-    """
-    Return dto containing status of configured resources in the cluster
-
-    reporter -- ReportProcessor
-    status -- status xml document from crm_mon, validated using
-        the appropriate rng schema
-    """
-    resources = cast(list[_Element], status.xpath("resources/*"))
-
-    resource_dtos = [
-        _TAG_TO_FUNCTION[resource.tag](reporter, resource)
-        for resource in resources
-        if resource.tag in _TAG_TO_FUNCTION
-    ]
+class ClusterStatusParser:
+    TAG_TO_FUNCTION = {
+        _PRIMITIVE_TAG: _primitive_to_dto,
+        _GROUP_TAG: _group_to_dto,
+        _CLONE_TAG: _clone_to_dto,
+        _BUNDLE_TAG: _bundle_to_dto,
+    }
+
+    def __init__(self, status: _Element):
+        self.status = status
+        self.warnings: reports.ReportItemList = []
+
+    def status_xml_to_dto(self) -> ResourcesStatusDto:
+        """
+        Return dto containing status of configured resources in the cluster
+
+        status -- status xml document from crm_mon, validated using
+            the appropriate rng schema
+        """
+        resource_list = cast(list[_Element], self.status.xpath("resources/*"))
+
+        resource_dto_list = []
+        for resource in resource_list:
+            try:
+                resource_dto = cast(
+                    AnyResourceStatusDto,
+                    self.TAG_TO_FUNCTION[resource.tag](resource),
+                )
+                resource_dto_list.append(resource_dto)
+            except BundleSameIdAsImplicitResourceError as e:
+                # This is the only error that the user can cause directly by
+                # setting the name of the bundle member to be same as one of
+                # the implicitly created resource.
+                # We only skip such bundles while still providing status of the
+                # other resources.
+                self.warnings.append(
+                    reports.ReportItem.warning(
+                        reports.messages.ClusterStatusBundleMemberIdAsImplicit(
+                            e.bundle_id, e.bad_ids
+                        )
+                    )
+                )
 
-    if reporter.has_errors:
-        raise LibraryError()
+        return ResourcesStatusDto(resource_dto_list)
 
-    return ResourcesStatusDto(
-        cast(
-            list[AnyResourceStatusDto],
-            [dto for dto in resource_dtos if dto is not None],
-        )
-    )
+    def get_warnings(self) -> reports.ReportItemList:
+        return self.warnings
 
 
-def _get_resource_id(reporter: ReportProcessor, resource: _Element) -> str:
+def _get_resource_id(resource: _Element) -> str:
     resource_id = resource.get("id")
     if not resource_id:
-        reporter.report(
-            reports.ReportItem.error(
-                reports.messages.InvalidIdIsEmpty("resource id")
-            )
-        )
+        raise EmptyResourceIdError()
     return str(resource_id)
 
 
-def _get_role(
-    reporter: ReportProcessor, resource: _Element, resource_id: str
-) -> PcmkStatusRoleType:
+def _get_role(resource: _Element) -> PcmkStatusRoleType:
     role = resource.get("role")
     if role is None or role not in PCMK_STATUS_ROLES:
-        reporter.report(
-            reports.ReportItem.warning(
-                reports.messages.ClusterStatusUnknownPcmkRole(role, resource_id)
-            )
-        )
-        return PCMK_STATUS_ROLE_UNKNOWN
+        raise UnknownPcmkRoleError(str(resource.get("id")), str(role))
     return PcmkStatusRoleType(role)
 
 
-def _get_target_role(
-    reporter: ReportProcessor, resource: _Element, resource_id: str
-) -> Optional[PcmkRoleType]:
+def _get_target_role(resource: _Element) -> Optional[PcmkRoleType]:
     target_role = resource.get("target_role")
     if target_role is None:
         return None
     if target_role not in PCMK_ROLES:
-        reporter.report(
-            reports.ReportItem.warning(
-                reports.messages.ClusterStatusUnknownPcmkRole(
-                    target_role, resource_id
-                )
-            )
-        )
-        return PCMK_ROLE_UNKNOWN
+        raise UnknownPcmkRoleError(str(resource.get("id")), target_role)
     return PcmkRoleType(target_role)
 
 
@@ -299,130 +371,66 @@ def _remove_clone_suffix(resource_id: str) -> str:
     return resource_id
 
 
-def _validate_mixed_instance_types(
-    primitives: list[PrimitiveStatusDto],
-    groups: list[GroupStatusDto],
-    clone_id: str,
-) -> reports.ReportItemList:
-    if primitives and groups:
-        return [
-            reports.ReportItem.error(
-                reports.messages.ClusterStatusCloneMixedMembers(clone_id)
-            )
-        ]
-    return []
-
-
-def _validate_primitive_instance_ids(
-    instances: list[PrimitiveStatusDto], clone_id: str
-) -> reports.ReportItemList:
-    if len(set(res.resource_id for res in instances)) > 1:
-        return [
-            reports.ReportItem.error(
-                reports.messages.ClusterStatusCloneMembersDifferentIds(clone_id)
-            )
-        ]
-    return []
-
-
-def _validate_group_instance_ids(
-    instances: list[GroupStatusDto], clone_id: str
-) -> reports.ReportItemList:
-    group_ids = set(group.resource_id for group in instances)
-    children_ids = set(
-        tuple(child.resource_id for child in group.members)
-        for group in instances
-    )
-
-    if len(group_ids) > 1 or len(children_ids) > 1:
-        return [
-            reports.ReportItem.error(
-                reports.messages.ClusterStatusCloneMembersDifferentIds(clone_id)
-            )
-        ]
-    return []
-
-
 def _replica_to_dto(
-    reporter: ReportProcessor,
-    replica_el: _Element,
-    bundle_id: str,
-    bundle_type: str,
-) -> Optional[BundleReplicaStatusDto]:
+    replica_el: _Element, bundle_id: str, bundle_type: str
+) -> BundleReplicaStatusDto:
     replica_id = str(replica_el.get("id"))
 
-    resources = [
-        _primitive_to_dto(reporter, resource)
+    resource_list = [
+        _primitive_to_dto(resource)
         for resource in replica_el.iterfind(_PRIMITIVE_TAG)
     ]
 
-    duplicate_ids = _find_duplicate_ids(resources)
+    duplicate_ids = [
+        id
+        for id, count in Counter(
+            resource.resource_id for resource in resource_list
+        ).items()
+        if count > 1
+    ]
+
     if duplicate_ids:
-        reporter.report(
-            reports.ReportItem.warning(
-                reports.messages.ClusterStatusBundleMemberIdAsImplicit(
-                    bundle_id, duplicate_ids
-                )
-            )
-        )
-        return None
+        raise BundleSameIdAsImplicitResourceError(bundle_id, duplicate_ids)
 
     # TODO pacemaker will probably add prefix
     # "pcmk-internal" to all implicit resources
 
-    container_resource = _get_implicit_resource(
-        resources,
+    container_resource = _pop_implicit_resource(
+        resource_list,
         f"{bundle_id}-{bundle_type}-{replica_id}",
         True,
         f"ocf:heartbeat:{bundle_type}",
     )
 
     if container_resource is None:
-        reporter.report(
-            reports.ReportItem.error(
-                reports.messages.ClusterStatusBundleReplicaNoContainer(
-                    bundle_id, replica_id
-                )
-            )
+        raise BundleReplicaMissingImplicitResourceError(
+            bundle_id, replica_id, "container"
         )
-        raise LibraryError()
 
-    remote_resource = _get_implicit_resource(
-        resources, f"{bundle_id}-{replica_id}", True, "ocf:pacemaker:remote"
+    remote_resource = _pop_implicit_resource(
+        resource_list, f"{bundle_id}-{replica_id}", True, "ocf:pacemaker:remote"
     )
 
     # implicit ip address resource might be present
     ip_resource = None
-    if (remote_resource is not None and len(resources) == 2) or (
-        remote_resource is None and len(resources) == 1
+    if (remote_resource is not None and len(resource_list) == 2) or (
+        remote_resource is None and len(resource_list) == 1
     ):
-        ip_resource = _get_implicit_resource(
-            resources, f"{bundle_id}-ip-", False, "ocf:heartbeat:IPaddr2"
+        ip_resource = _pop_implicit_resource(
+            resource_list, f"{bundle_id}-ip-", False, "ocf:heartbeat:IPaddr2"
         )
 
-    if remote_resource is None and resources:
-        reporter.report(
-            reports.ReportItem.error(
-                reports.messages.ClusterStatusBundleReplicaMissingRemote(
-                    bundle_id, replica_id
-                )
-            )
+    if remote_resource is None and resource_list:
+        raise BundleReplicaMissingImplicitResourceError(
+            bundle_id, replica_id, "remote"
         )
-        raise LibraryError()
 
     member = None
     if remote_resource:
-        if len(resources) == 1:
-            member = resources[0]
+        if len(resource_list) == 1:
+            member = resource_list[0]
         else:
-            reporter.report(
-                reports.ReportItem.error(
-                    reports.messages.ClusterStatusBundleReplicaInvalidCount(
-                        bundle_id, replica_id
-                    )
-                )
-            )
-            raise LibraryError()
+            raise BundleReplicaInvalidMemberCountError(bundle_id, replica_id)
 
     return BundleReplicaStatusDto(
         replica_id,
@@ -433,24 +441,13 @@ def _replica_to_dto(
     )
 
 
-def _find_duplicate_ids(resources: Sequence[AnyResourceStatusDto]) -> list[str]:
-    seen = set()
-    duplicates = []
-    for resource in resources:
-        if resource.resource_id in seen:
-            duplicates.append(resource.resource_id)
-        else:
-            seen.add(resource.resource_id)
-    return duplicates
-
-
-def _get_implicit_resource(
-    primitives: list[PrimitiveStatusDto],
+def _pop_implicit_resource(
+    primitive_list: list[PrimitiveStatusDto],
     expected_id: str,
     exact_match: bool,
     resource_agent: str,
 ) -> Optional[PrimitiveStatusDto]:
-    for primitive in primitives:
+    for primitive in primitive_list:
         matching_id = (
             exact_match
             and primitive.resource_id == expected_id
@@ -459,36 +456,28 @@ def _get_implicit_resource(
         )
 
         if matching_id and primitive.resource_agent == resource_agent:
-            primitives.remove(primitive)
+            primitive_list.remove(primitive)
             return primitive
 
     return None
 
 
-def _validate_replicas(
-    replicas: Sequence[BundleReplicaStatusDto], bundle_id: str
-) -> reports.ReportItemList:
-    if not replicas:
-        return []
+def _replicas_valid(replica_list: Sequence[BundleReplicaStatusDto]) -> bool:
+    if not replica_list:
+        return True
 
-    member = replicas[0].member
-    ip = replicas[0].ip_address
-    container = replicas[0].container
+    member = replica_list[0].member
+    ip = replica_list[0].ip_address
+    container = replica_list[0].container
 
-    for replica in replicas:
+    for replica in replica_list:
         if (
             not _cmp_replica_members(member, replica.member, True)
             or not _cmp_replica_members(ip, replica.ip_address, False)
             or not _cmp_replica_members(container, replica.container, False)
         ):
-            return [
-                reports.ReportItem.error(
-                    reports.messages.ClusterStatusBundleDifferentReplicas(
-                        bundle_id
-                    )
-                )
-            ]
-    return []
+            return False
+    return True
 
 
 def _cmp_replica_members(
diff --git a/pcs_test/resources/crm_mon.all_resources.xml b/pcs_test/resources/crm_mon.all_resources.xml
index e493d308..f11db064 100644
--- a/pcs_test/resources/crm_mon.all_resources.xml
+++ b/pcs_test/resources/crm_mon.all_resources.xml
@@ -1,40 +1,53 @@
-<pacemaker-result api-version="2.30" request="crm_mon --output-as xml">
+<pacemaker-result api-version="2.30" request="crm_mon --one-shot --inactive --output-as xml">
   <summary>
-    <stack type="corosync" />
-    <current_dc present="false" />
-    <last_update time="Wed Nov  6 13:45:41 2019" />
-    <last_change time="Wed Nov  6 10:42:54 2019" user="hacluster" client="crmd" origin="node1" />
-    <nodes_configured number="0" />
-    <resources_configured number="0" disabled="0" blocked="0" />
+    <stack type="unknown"/>
+    <current_dc present="false"/>
+    <last_update time="Wed Jan 31 12:03:35 2024"/>
+    <last_change time="Thu Aug 23 16:49:17 2012" user="" client="crmd" origin="rh7-3"/>
+    <nodes_configured number="0"/>
+    <resources_configured number="16" disabled="8" blocked="0"/>
     <cluster_options stonith-enabled="true" symmetric-cluster="true" no-quorum-policy="stop" maintenance-mode="false" stop-all-resources="false" stonith-timeout-ms="60000" priority-fencing-delay-ms="0"/>
   </summary>
-  <nodes>
-    <node name="node1" id="1" online="true" standby="false" standby_onfail="false" maintenance="false" pending="false" unclean="false" health="green" feature_set="3.17.4" shutdown="false" expected_up="true" is_dc="false" resources_running="16" type="member"/>
-  </nodes>
+  <nodes/>
   <resources>
-    <resource id="dummy" resource_agent="ocf:pacemaker:Dummy" role="Started" active="true" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="1">
-      <node name="node1" id="1" cached="true"/>
-    </resource>
-    <group id="group" number_resources="2" maintenance="false" managed="true" disabled="false">
-      <resource id="grouped" resource_agent="ocf:pacemaker:Dummy" role="Started" active="true" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="1">
-        <node name="node1" id="1" cached="true"/>
-      </resource>
-    </group>
-    <clone id="clone" multi_state="false" unique="false" maintenance="false" managed="true" disabled="false" failed="false" failure_ignored="false">
-      <resource id="cloned" resource_agent="ocf:pacemaker:Dummy" role="Started" active="true" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="1">
-        <node name="node1" id="1" cached="true"/>
-      </resource>
-    </clone>
-    <bundle id="bundle" type="podman" image="localhost/pcmktest:http" unique="false" maintenance="false" managed="true" failed="false">
+    <bundle id="B1" type="docker" image="pcs:test" unique="true" maintenance="false" managed="false" failed="false">
       <replica id="0">
-        <resource id="bundle-ip-192.168.122.250" resource_agent="ocf:heartbeat:IPaddr2" role="Started" active="true" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="1">
-          <node name="node1" id="1" cached="true"/>
-        </resource>
-        <resource id="bundle-podman-0" resource_agent="ocf:heartbeat:podman" role="Started" active="true" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="1">
-          <node name="node1" id="1" cached="true"/>
-        </resource>
+        <resource id="B1-ip-192.168.100.200" resource_agent="ocf:heartbeat:IPaddr2" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+        <resource id="B1-docker-0" resource_agent="ocf:heartbeat:docker" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+      </replica>
+      <replica id="1">
+        <resource id="B1-ip-192.168.100.201" resource_agent="ocf:heartbeat:IPaddr2" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+        <resource id="B1-docker-1" resource_agent="ocf:heartbeat:docker" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+      </replica>
+      <replica id="2">
+        <resource id="B1-ip-192.168.100.202" resource_agent="ocf:heartbeat:IPaddr2" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+        <resource id="B1-docker-2" resource_agent="ocf:heartbeat:docker" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+      </replica>
+      <replica id="3">
+        <resource id="B1-ip-192.168.100.203" resource_agent="ocf:heartbeat:IPaddr2" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+        <resource id="B1-docker-3" resource_agent="ocf:heartbeat:docker" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
       </replica>
     </bundle>
+    <resource id="R7" resource_agent="ocf:pacemaker:Dummy" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+    <resource id="S2" resource_agent="stonith:fence_kdump" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+    <group id="G2" number_resources="2" maintenance="false" managed="true" disabled="false">
+      <resource id="R5" resource_agent="ocf:pacemaker:Dummy" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+      <resource id="S1" resource_agent="stonith:fence_kdump" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+    </group>
+    <clone id="G1-clone" multi_state="true" unique="false" maintenance="false" managed="true" disabled="false" failed="false" failure_ignored="false">
+      <group id="G1:0" number_resources="3" maintenance="false" managed="true" disabled="false">
+        <resource id="R2" resource_agent="ocf:pacemaker:Stateful" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+        <resource id="R3" resource_agent="ocf:pacemaker:Stateful" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+        <resource id="R4" resource_agent="ocf:pacemaker:Stateful" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+      </group>
+    </clone>
+    <clone id="R6-clone" multi_state="false" unique="false" maintenance="false" managed="true" disabled="false" failed="false" failure_ignored="false">
+      <resource id="R6" resource_agent="ocf:pacemaker:Dummy" role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+    </clone>
   </resources>
-  <status code="0" message="OK" />
+  <tickets>
+    <ticket id="custom-ticket1" status="revoked" standby="false"/>
+    <ticket id="ticket2" status="revoked" standby="false"/>
+  </tickets>
+  <status code="0" message="OK"/>
 </pacemaker-result>
diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py
index b60360e4..48eb730c 100644
--- a/pcs_test/tier0/common/reports/test_messages.py
+++ b/pcs_test/tier0/common/reports/test_messages.py
@@ -2195,6 +2195,26 @@ class BadClusterStateFormat(NameBuildTest):
         )
 
 
+class BadClusterState(NameBuildTest):
+    def test_no_reason(self):
+        self.assert_message_from_report(
+            (
+                "Cannot load cluster status, xml does not describe "
+                "valid cluster status."
+            ),
+            reports.BadClusterState(),
+        )
+
+    def test_reason(self):
+        self.assert_message_from_report(
+            (
+                "Cannot load cluster status, xml does not describe "
+                "valid cluster status: sample reason."
+            ),
+            reports.BadClusterState("sample reason"),
+        )
+
+
 class WaitForIdleStarted(NameBuildTest):
     def test_timeout(self):
         timeout = 20
@@ -5818,143 +5838,26 @@ class CannotCreateDefaultClusterPropertySet(NameBuildTest):
         )
 
 
-class ClusterStatusBundleDifferentReplicas(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            "Replicas of bundle 'bundle' are not the same.",
-            reports.ClusterStatusBundleDifferentReplicas("bundle"),
-        )
-
-
 class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest):
-    def test_message(self):
+    def test_one(self):
         self.assert_message_from_report(
             (
-                "Skipping bundle 'bundle': resource 'test' has "
-                "the same id as some of the implicit bundle resources."
-            ),
-            reports.ClusterStatusBundleMemberIdAsImplicit("bundle", ["test"]),
-        )
-
-    def test_multiple_ids(self):
-        self.assert_message_from_report(
-            (
-                "Skipping bundle 'bundle': resources 'test1', 'test2' have "
+                "Skipping bundle 'resource-bundle': resource 'resource' has "
                 "the same id as some of the implicit bundle resources."
             ),
             reports.ClusterStatusBundleMemberIdAsImplicit(
-                "bundle", ["test1", "test2"]
-            ),
-        )
-
-
-class ClusterStatusBundleReplicaInvalidCount(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            (
-                "Replica '0' of bundle 'bundle' has invalid number of members. "
-                "Expecting 2-4 members."
+                "resource-bundle", ["resource"]
             ),
-            reports.ClusterStatusBundleReplicaInvalidCount("bundle", "0"),
         )
 
-
-class ClusterStatusBundleReplicaMissingRemote(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            (
-                "Replica '0' of bundle 'bundle' is missing implicit pacemaker "
-                "remote resource while it must be present."
-            ),
-            reports.ClusterStatusBundleReplicaMissingRemote("bundle", "0"),
-        )
-
-
-class ClusterStatusBundleReplicaNoContainer(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            (
-                "Replica '0' of bundle 'bundle' is missing implicit container "
-                "resource."
-            ),
-            reports.ClusterStatusBundleReplicaNoContainer("bundle", "0"),
-        )
-
-
-class ClusterStatusCloneMembersDifferentIds(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            "Members with different ids in clone 'clone'.",
-            reports.ClusterStatusCloneMembersDifferentIds("clone"),
-        )
-
-
-class ClusterStatusCloneMixedMembers(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            "Primitive and group members mixed in clone 'clone'.",
-            reports.ClusterStatusCloneMixedMembers("clone"),
-        )
-
-
-class ClusterStatusEmptyNodeName(NameBuildTest):
-    def test_message(self):
-        self.assert_message_from_report(
-            "Resource with id 'resource' contains node with empty name.",
-            reports.ClusterStatusEmptyNodeName("resource"),
-        )
-
-
-class ClusterStatusUnexpectedMember(NameBuildTest):
-    def test_one_expected(self):
-        self.assert_message_from_report(
-            (
-                "Unexpected resource 'member' inside of resource 'resource' of "
-                "type 'group'. Only resources of type 'primitive' "
-                "can be in group."
-            ),
-            reports.ClusterStatusUnexpectedMember(
-                resource_id="resource",
-                resource_type="group",
-                member_id="member",
-                expected_types=["primitive"],
-            ),
-        )
-
-    def test_multiple_expected(self):
+    def test_multiple(self):
         self.assert_message_from_report(
             (
-                "Unexpected resource 'member' inside of resource 'resource' of "
-                "type 'clone'. Only resources of type 'group'|'primitive' "
-                "can be in clone."
-            ),
-            reports.ClusterStatusUnexpectedMember(
-                resource_id="resource",
-                resource_type="clone",
-                member_id="member",
-                expected_types=["primitive", "group"],
+                "Skipping bundle 'resource-bundle': resources 'resource-0', "
+                "'resource-1' have the same id as some of the implicit bundle "
+                "resources."
             ),
-        )
-
-
-class ClusterStatusUnknownPcmkRole(NameBuildTest):
-    def test_no_role(self):
-        self.assert_message_from_report(
-            "Attribute of resource with id 'resource' contains empty pcmk role.",
-            reports.ClusterStatusUnknownPcmkRole(None, "resource"),
-        )
-
-    def test_empty_role(self):
-        self.assert_message_from_report(
-            "Attribute of resource with id 'resource' contains empty pcmk role.",
-            reports.ClusterStatusUnknownPcmkRole("", "resource"),
-        )
-
-    def test_role(self):
-        self.assert_message_from_report(
-            (
-                "Attribute of resource with id 'resource' contains invalid "
-                "pcmk role 'NotValidRole'."
+            reports.ClusterStatusBundleMemberIdAsImplicit(
+                "resource-bundle", ["resource-0", "resource-1"]
             ),
-            reports.ClusterStatusUnknownPcmkRole("NotValidRole", "resource"),
         )
diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py
index a5a395b5..3b6b7665 100644
--- a/pcs_test/tier0/lib/commands/test_status.py
+++ b/pcs_test/tier0/lib/commands/test_status.py
@@ -1,5 +1,7 @@
+# pylint: disable=too-many-lines
 import os
 from textwrap import dedent
+from typing import Optional
 from unittest import (
     TestCase,
     mock,
@@ -7,7 +9,11 @@ from unittest import (
 
 from pcs import settings
 from pcs.common import file_type_codes
-from pcs.common.const import PCMK_STATUS_ROLE_STARTED
+from pcs.common.const import (
+    PCMK_ROLE_STOPPED,
+    PCMK_STATUS_ROLE_STOPPED,
+    PcmkRoleType,
+)
 from pcs.common.reports import codes as report_codes
 from pcs.common.status_dto import (
     BundleReplicaStatusDto,
@@ -1267,24 +1273,27 @@ class FullClusterStatusPlaintextBoothWarning(FullClusterStatusPlaintextBase):
 
 
 def _fixture_primitive_resource_dto(
-    resource_id: str, resource_agent: str
+    resource_id: str,
+    resource_agent: str,
+    target_role: Optional[PcmkRoleType] = None,
+    managed: bool = True,
 ) -> PrimitiveStatusDto:
     return PrimitiveStatusDto(
-        resource_id,
-        resource_agent,
-        PCMK_STATUS_ROLE_STARTED,
-        None,
-        True,
-        False,
-        False,
-        False,
-        None,
-        False,
-        True,
-        False,
-        ["node1"],
-        None,
-        None,
+        resource_id=resource_id,
+        resource_agent=resource_agent,
+        role=PCMK_STATUS_ROLE_STOPPED,
+        target_role=target_role,
+        active=False,
+        orphaned=False,
+        blocked=False,
+        maintenance=False,
+        description=None,
+        failed=False,
+        managed=managed,
+        failure_ignored=False,
+        node_names=[],
+        pending=None,
+        locked_to=None,
     )
 
 
@@ -1303,7 +1312,7 @@ class ResourcesStatus(TestCase):
         result = status.resources_status(self.env_assist.get_env())
         self.assertEqual(result, ResourcesStatusDto([]))
 
-    def test_bad_xml(self):
+    def test_bad_xml_format(self):
         self.config.runner.pcmk.load_state(
             resources="""
                 <resources>
@@ -1320,6 +1329,26 @@ class ResourcesStatus(TestCase):
             False,
         )
 
+    def test_bad_xml(self):
+        self.config.runner.pcmk.load_state(
+            resources="""
+                <resources>
+                    <resource id="R7" role="NotPcmkRole" resource_agent="ocf:pacemaker:Dummy" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+                </resources>
+            """,
+        )
+
+        self.env_assist.assert_raise_library_error(
+            lambda: status.resources_status(self.env_assist.get_env()),
+            [
+                fixture.error(
+                    report_codes.BAD_CLUSTER_STATE,
+                    reason="Resource with id 'R7' contains unknown pcmk role 'NotPcmkRole'.",
+                ),
+            ],
+            False,
+        )
+
     def test_all_resources(self):
         self.config.runner.pcmk.load_state(
             filename=rc("crm_mon.all_resources.xml")
@@ -1327,69 +1356,187 @@ class ResourcesStatus(TestCase):
 
         result = status.resources_status(self.env_assist.get_env())
 
-        self.assertTrue(len(result.resources) == 4)
         self.assertEqual(
-            result.resources[0],
-            _fixture_primitive_resource_dto("dummy", "ocf:pacemaker:Dummy"),
-        )
-        self.assertEqual(
-            result.resources[1],
-            GroupStatusDto(
-                "group",
-                False,
-                None,
-                True,
-                False,
-                members=[
+            result,
+            ResourcesStatusDto(
+                [
+                    BundleStatusDto(
+                        resource_id="B1",
+                        type="docker",
+                        image="pcs:test",
+                        unique=True,
+                        maintenance=False,
+                        description=None,
+                        managed=False,
+                        failed=False,
+                        replicas=[
+                            BundleReplicaStatusDto(
+                                replica_id="0",
+                                member=None,
+                                remote=None,
+                                container=_fixture_primitive_resource_dto(
+                                    "B1-docker-0",
+                                    "ocf:heartbeat:docker",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                                ip_address=_fixture_primitive_resource_dto(
+                                    "B1-ip-192.168.100.200",
+                                    "ocf:heartbeat:IPaddr2",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                            ),
+                            BundleReplicaStatusDto(
+                                replica_id="1",
+                                member=None,
+                                remote=None,
+                                container=_fixture_primitive_resource_dto(
+                                    "B1-docker-1",
+                                    "ocf:heartbeat:docker",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                                ip_address=_fixture_primitive_resource_dto(
+                                    "B1-ip-192.168.100.201",
+                                    "ocf:heartbeat:IPaddr2",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                            ),
+                            BundleReplicaStatusDto(
+                                replica_id="2",
+                                member=None,
+                                remote=None,
+                                container=_fixture_primitive_resource_dto(
+                                    "B1-docker-2",
+                                    "ocf:heartbeat:docker",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                                ip_address=_fixture_primitive_resource_dto(
+                                    "B1-ip-192.168.100.202",
+                                    "ocf:heartbeat:IPaddr2",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                            ),
+                            BundleReplicaStatusDto(
+                                replica_id="3",
+                                member=None,
+                                remote=None,
+                                container=_fixture_primitive_resource_dto(
+                                    "B1-docker-3",
+                                    "ocf:heartbeat:docker",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                                ip_address=_fixture_primitive_resource_dto(
+                                    "B1-ip-192.168.100.203",
+                                    "ocf:heartbeat:IPaddr2",
+                                    target_role=PCMK_ROLE_STOPPED,
+                                    managed=False,
+                                ),
+                            ),
+                        ],
+                    ),
                     _fixture_primitive_resource_dto(
-                        "grouped", "ocf:pacemaker:Dummy"
-                    )
-                ],
-            ),
-        )
-        self.assertEqual(
-            result.resources[2],
-            CloneStatusDto(
-                "clone",
-                False,
-                False,
-                False,
-                None,
-                True,
-                False,
-                False,
-                False,
-                None,
-                instances=[
+                        "R7", "ocf:pacemaker:Dummy"
+                    ),
                     _fixture_primitive_resource_dto(
-                        "cloned", "ocf:pacemaker:Dummy"
-                    )
-                ],
+                        "S2", "stonith:fence_kdump"
+                    ),
+                    GroupStatusDto(
+                        resource_id="G2",
+                        maintenance=False,
+                        description=None,
+                        managed=True,
+                        disabled=False,
+                        members=[
+                            _fixture_primitive_resource_dto(
+                                "R5", "ocf:pacemaker:Dummy"
+                            ),
+                            _fixture_primitive_resource_dto(
+                                "S1", "stonith:fence_kdump"
+                            ),
+                        ],
+                    ),
+                    CloneStatusDto(
+                        resource_id="G1-clone",
+                        multi_state=True,
+                        unique=False,
+                        maintenance=False,
+                        description=None,
+                        managed=True,
+                        disabled=False,
+                        failed=False,
+                        failure_ignored=False,
+                        target_role=None,
+                        instances=[
+                            GroupStatusDto(
+                                resource_id="G1",
+                                maintenance=False,
+                                description=None,
+                                managed=True,
+                                disabled=False,
+                                members=[
+                                    _fixture_primitive_resource_dto(
+                                        "R2", "ocf:pacemaker:Stateful"
+                                    ),
+                                    _fixture_primitive_resource_dto(
+                                        "R3", "ocf:pacemaker:Stateful"
+                                    ),
+                                    _fixture_primitive_resource_dto(
+                                        "R4", "ocf:pacemaker:Stateful"
+                                    ),
+                                ],
+                            )
+                        ],
+                    ),
+                    CloneStatusDto(
+                        resource_id="R6-clone",
+                        multi_state=False,
+                        unique=False,
+                        maintenance=False,
+                        description=None,
+                        managed=True,
+                        disabled=False,
+                        failed=False,
+                        failure_ignored=False,
+                        target_role=None,
+                        instances=[
+                            _fixture_primitive_resource_dto(
+                                "R6", "ocf:pacemaker:Dummy"
+                            )
+                        ],
+                    ),
+                ]
             ),
         )
-        self.assertEqual(
-            result.resources[3],
-            BundleStatusDto(
-                "bundle",
-                "podman",
-                "localhost/pcmktest:http",
-                False,
-                False,
-                None,
-                True,
-                False,
-                [
-                    BundleReplicaStatusDto(
-                        "0",
-                        None,
-                        None,
-                        _fixture_primitive_resource_dto(
-                            "bundle-podman-0", "ocf:heartbeat:podman"
-                        ),
-                        _fixture_primitive_resource_dto(
-                            "bundle-ip-192.168.122.250", "ocf:heartbeat:IPaddr2"
-                        ),
-                    )
-                ],
-            ),
+
+    def test_bundle_skip(self):
+        self.config.runner.pcmk.load_state(
+            resources="""
+                <resources>
+                    <bundle id="B1" type="docker" image="pcs:test" unique="true" maintenance="false" managed="false" failed="false">
+                        <replica id="0">
+                            <resource id="B1-0" resource_agent="ocf:heartbeat:Dummy" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="true" failed="false" failure_ignored="false" nodes_running_on="0"/>
+                            <resource id="B1-docker-0" resource_agent="ocf:heartbeat:docker" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+                            <resource id="B1-0" resource_agent="ocf:pacemaker:remote" role="Stopped" target_role="Stopped" active="false" orphaned="false" blocked="false" maintenance="false" managed="false" failed="false" failure_ignored="false" nodes_running_on="0"/>
+                        </replica>
+                    </bundle>
+                </resources>
+            """,
+        )
+
+        result = status.resources_status(self.env_assist.get_env())
+        self.assertEqual(result, ResourcesStatusDto([]))
+        self.env_assist.assert_reports(
+            [
+                fixture.warn(
+                    report_codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
+                    bundle_id="B1",
+                    bad_ids=["B1-0"],
+                )
+            ]
         )
diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py
index 451fb584..778e97a6 100644
--- a/pcs_test/tier0/lib/pacemaker/test_status.py
+++ b/pcs_test/tier0/lib/pacemaker/test_status.py
@@ -11,11 +11,9 @@ from lxml import etree
 from pcs.common import reports
 from pcs.common.const import (
     PCMK_ROLE_STARTED,
-    PCMK_ROLE_UNKNOWN,
     PCMK_ROLES,
     PCMK_STATUS_ROLE_STARTED,
     PCMK_STATUS_ROLE_STOPPED,
-    PCMK_STATUS_ROLE_UNKNOWN,
     PCMK_STATUS_ROLE_UNPROMOTED,
     PCMK_STATUS_ROLES,
     PCMK_STATUS_ROLES_PENDING,
@@ -34,10 +32,9 @@ from pcs.lib.pacemaker import status
 
 from pcs_test.tools import fixture
 from pcs_test.tools.assertions import (
-    assert_raise_library_error,
+    assert_report_item_equal,
     assert_report_item_list_equal,
 )
-from pcs_test.tools.custom_mock import MockLibraryReportProcessor
 
 
 def fixture_primitive_xml(
@@ -327,34 +324,163 @@ def fixture_crm_mon_xml(resources: list[str]) -> str:
     """
 
 
+class TestParsingErrorToReport(TestCase):
+    # pylint: disable=no-self-use
+
+    def test_empty_resource_id(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.EmptyResourceIdError()
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Resource with empty id.",
+            ),
+        )
+
+    def test_empty_node_name(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.EmptyNodeNameError("resource")
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Resource with id 'resource' contains node with empty name.",
+            ),
+        )
+
+    def test_unknow_pcmk_role(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.UnknownPcmkRoleError("resource", "NotPcmkRole")
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Resource with id 'resource' contains unknown pcmk role 'NotPcmkRole'.",
+            ),
+        )
+
+    def test_unexpected_member_group(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.UnexpectedMemberError(
+                "resource", "group", "member", ["primitive"]
+            )
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason=(
+                    "Unexpected resource 'member' inside of resource "
+                    "'resource' of type 'group'. Only resources of type "
+                    "'primitive' can be in group."
+                ),
+            ),
+        )
+
+    def test_unexpected_member_clone(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.UnexpectedMemberError(
+                "resource", "clone", "member", ["primitive", "group"]
+            )
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason=(
+                    "Unexpected resource 'member' inside of resource "
+                    "'resource' of type 'clone'. Only resources of type "
+                    "'group'|'primitive' can be in clone."
+                ),
+            ),
+        )
+
+    def test_mixed_members(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.MixedMembersError("resource")
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Primitive and group members mixed in clone 'resource'.",
+            ),
+        )
+
+    def test_different_member_ids(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.DifferentMemberIdsError("resource")
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Members with different ids in resource 'resource'.",
+            ),
+        )
+
+    def test_bundle_replica_missing_implicit(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.BundleReplicaMissingImplicitResourceError(
+                "resource", "0", "container"
+            )
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Replica '0' of bundle 'resource' is missing implicit container resource.",
+            ),
+        )
+
+    def test_bundle_replica_invalid_member_count(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.BundleReplicaInvalidMemberCountError("resource", "0")
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Replica '0' of bundle 'resource' has invalid number of members.",
+            ),
+        )
+
+    def test_bundle_different_replicas(self):
+        report = status.cluster_status_parsing_error_to_report(
+            status.BundleDifferentReplicas("resource")
+        )
+        assert_report_item_equal(
+            report,
+            fixture.error(
+                reports.codes.BAD_CLUSTER_STATE,
+                reason="Replicas of bundle 'resource' are not the same.",
+            ),
+        )
+
+
 class TestPrimitiveStatusToDto(TestCase):
     # pylint: disable=protected-access
-    def setUp(self):
-        self.report_processor = MockLibraryReportProcessor()
-
     def test_simple(self):
         primitive_xml = etree.fromstring(fixture_primitive_xml())
 
-        result = status._primitive_to_dto(self.report_processor, primitive_xml)
+        result = status._primitive_to_dto(primitive_xml)
 
         self.assertEqual(result, fixture_primitive_dto())
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_empty_node_list(self):
         primitive_xml = etree.fromstring(
             fixture_primitive_xml(role=PCMK_STATUS_ROLE_STOPPED, node_names=[])
         )
-        result = status._primitive_to_dto(self.report_processor, primitive_xml)
+        result = status._primitive_to_dto(primitive_xml)
 
         self.assertEqual(
             result,
             fixture_primitive_dto(role=PCMK_STATUS_ROLE_STOPPED, node_names=[]),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_all_attributes(self):
         primitive_xml = etree.fromstring(
@@ -363,7 +489,7 @@ class TestPrimitiveStatusToDto(TestCase):
             )
         )
 
-        result = status._primitive_to_dto(self.report_processor, primitive_xml)
+        result = status._primitive_to_dto(primitive_xml)
 
         self.assertEqual(
             result,
@@ -371,74 +497,40 @@ class TestPrimitiveStatusToDto(TestCase):
                 target_role=PCMK_STATUS_ROLE_STOPPED, add_optional_args=True
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_remove_clone_suffix(self):
         primitive_xml = etree.fromstring(
             fixture_primitive_xml(resource_id="resource:0")
         )
 
-        result = status._primitive_to_dto(
-            self.report_processor, primitive_xml, True
-        )
+        result = status._primitive_to_dto(primitive_xml, True)
 
         self.assertEqual(result, fixture_primitive_dto())
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_running_on_multiple_nodes(self):
         primitive_xml = etree.fromstring(
             fixture_primitive_xml(node_names=["node1", "node2", "node3"])
         )
 
-        result = status._primitive_to_dto(self.report_processor, primitive_xml)
+        result = status._primitive_to_dto(primitive_xml)
 
         self.assertEqual(
             result,
             fixture_primitive_dto(node_names=["node1", "node2", "node3"]),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_empty_node_name(self):
         primitive_xml = etree.fromstring(fixture_primitive_xml(node_names=[""]))
 
-        assert_raise_library_error(
-            lambda: status._primitive_to_dto(
-                self.report_processor, primitive_xml
-            )
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_EMPTY_NODE_NAME,
-                    resource_id="resource",
-                )
-            ],
-        )
+        with self.assertRaises(status.EmptyNodeNameError) as cm:
+            status._primitive_to_dto(primitive_xml)
+        self.assertEqual(cm.exception.resource_id, "resource")
 
     def test_empty_resource_id(self):
         primitive_xml = etree.fromstring(fixture_primitive_xml(resource_id=""))
 
-        assert_raise_library_error(
-            lambda: status._primitive_to_dto(
-                self.report_processor, primitive_xml
-            )
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.INVALID_ID_IS_EMPTY,
-                    id_description="resource id",
-                )
-            ],
-        )
+        with self.assertRaises(status.EmptyResourceIdError):
+            status._primitive_to_dto(primitive_xml)
 
     def test_role(self):
         for role in PCMK_STATUS_ROLES:
@@ -446,35 +538,17 @@ class TestPrimitiveStatusToDto(TestCase):
                 primitive_xml = etree.fromstring(
                     fixture_primitive_xml(role=role)
                 )
-
-                result = status._primitive_to_dto(
-                    self.report_processor, primitive_xml
-                )
+                result = status._primitive_to_dto(primitive_xml)
                 self.assertEqual(result, fixture_primitive_dto(role=role))
-                assert_report_item_list_equal(
-                    self.report_processor.report_item_list, []
-                )
 
     def test_invalid_role(self):
         primitive_xml = etree.fromstring(
             fixture_primitive_xml(role="NotPcmkRole")
         )
 
-        result = status._primitive_to_dto(self.report_processor, primitive_xml)
-
-        self.assertEqual(
-            result, fixture_primitive_dto(role=PCMK_STATUS_ROLE_UNKNOWN)
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.warn(
-                    reports.codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE,
-                    role="NotPcmkRole",
-                    resource_id="resource",
-                )
-            ],
-        )
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._primitive_to_dto(primitive_xml)
+        self.assertEqual(cm.exception.resource_id, "resource")
 
     def test_target_role(self):
         for role in PCMK_ROLES:
@@ -483,76 +557,47 @@ class TestPrimitiveStatusToDto(TestCase):
                     fixture_primitive_xml(target_role=role)
                 )
 
-                result = status._primitive_to_dto(
-                    self.report_processor, primitive_xml
-                )
+                result = status._primitive_to_dto(primitive_xml)
 
                 self.assertEqual(
                     result, fixture_primitive_dto(target_role=role)
                 )
-                assert_report_item_list_equal(
-                    self.report_processor.report_item_list, []
-                )
 
     def test_invalid_target_role(self):
         for value in PCMK_STATUS_ROLES_PENDING + ("NotPcmkRole",):
             with self.subTest(value=value):
-                self.setUp()
                 primitive_xml = etree.fromstring(
                     fixture_primitive_xml(target_role=value)
                 )
 
-                result = status._primitive_to_dto(
-                    self.report_processor, primitive_xml
-                )
-
-                self.assertEqual(
-                    result, fixture_primitive_dto(target_role=PCMK_ROLE_UNKNOWN)
-                )
-                assert_report_item_list_equal(
-                    self.report_processor.report_item_list,
-                    [
-                        fixture.warn(
-                            reports.codes.CLUSTER_STATUS_UNKNOWN_PCMK_ROLE,
-                            role=value,
-                            resource_id="resource",
-                        )
-                    ],
-                )
+                with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+                    status._primitive_to_dto(primitive_xml)
+                self.assertEqual(cm.exception.resource_id, "resource")
 
 
 class TestGroupStatusToDto(TestCase):
     # pylint: disable=protected-access
-    def setUp(self):
-        self.report_processor = MockLibraryReportProcessor()
-
     def test_all_attributes(self):
         group_xml = etree.fromstring(
             fixture_group_xml(description="Test description")
         )
 
-        result = status._group_to_dto(self.report_processor, group_xml)
+        result = status._group_to_dto(group_xml)
 
         self.assertEqual(
             result, fixture_group_dto(description="Test description")
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_single_member(self):
         group_xml = etree.fromstring(
             fixture_group_xml(members=[fixture_primitive_xml()])
         )
 
-        result = status._group_to_dto(self.report_processor, group_xml)
+        result = status._group_to_dto(group_xml)
 
         self.assertEqual(
             result, fixture_group_dto(members=[fixture_primitive_dto()])
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_multiple_members(self):
         group_xml = etree.fromstring(
@@ -564,7 +609,7 @@ class TestGroupStatusToDto(TestCase):
             )
         )
 
-        result = status._group_to_dto(self.report_processor, group_xml)
+        result = status._group_to_dto(group_xml)
 
         self.assertEqual(
             result,
@@ -575,9 +620,6 @@ class TestGroupStatusToDto(TestCase):
                 ]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_multiple_members_different_state(self):
         group_xml = etree.fromstring(
@@ -594,7 +636,7 @@ class TestGroupStatusToDto(TestCase):
             )
         )
 
-        result = status._group_to_dto(self.report_processor, group_xml)
+        result = status._group_to_dto(group_xml)
 
         self.assertEqual(
             result,
@@ -610,10 +652,31 @@ class TestGroupStatusToDto(TestCase):
                 ]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
+
+    def test_member_invalid_role(self):
+        group_xml = etree.fromstring(
+            fixture_group_xml(
+                members=[fixture_primitive_xml(role="NotPcmkRole")]
+            )
+        )
+
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._group_to_dto(group_xml)
+        self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
+
+    def test_member_invalid_target_role(self):
+        group_xml = etree.fromstring(
+            fixture_group_xml(
+                members=[fixture_primitive_xml(target_role="NotPcmkRole")]
+            )
         )
 
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._group_to_dto(group_xml)
+        self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
+
     def test_invalid_member(self):
         resources = {
             "inner-group": '<group id="inner-group" number_resources="0" maintenance="false" managed="true" disabled="false" />',
@@ -623,31 +686,17 @@ class TestGroupStatusToDto(TestCase):
 
         for resource_id, member in resources.items():
             with self.subTest(value=resource_id):
-                self.setUp()
                 group_xml = etree.fromstring(
                     fixture_group_xml(
                         resource_id="outer-group", members=[member]
                     )
                 )
 
-                # pylint: disable=cell-var-from-loop
-                assert_raise_library_error(
-                    lambda: status._group_to_dto(
-                        self.report_processor, group_xml
-                    )
-                )
-                assert_report_item_list_equal(
-                    self.report_processor.report_item_list,
-                    [
-                        fixture.error(
-                            reports.codes.CLUSTER_STATUS_UNEXPECTED_MEMBER,
-                            resource_id="outer-group",
-                            resource_type="group",
-                            member_id=resource_id,
-                            expected_types=["primitive"],
-                        )
-                    ],
-                )
+                with self.assertRaises(status.UnexpectedMemberError) as cm:
+                    status._group_to_dto(group_xml)
+                self.assertEqual(cm.exception.resource_id, "outer-group")
+                self.assertEqual(cm.exception.member_id, resource_id)
+                self.assertEqual(cm.exception.expected_types, ["primitive"])
 
     def test_remove_clone_suffix(self):
         group_xml = etree.fromstring(
@@ -657,21 +706,15 @@ class TestGroupStatusToDto(TestCase):
             )
         )
 
-        result = status._group_to_dto(self.report_processor, group_xml, True)
+        result = status._group_to_dto(group_xml, True)
         self.assertEqual(
             result,
             fixture_group_dto(members=[fixture_primitive_dto()]),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
 
 class TestCloneStatusToDto(TestCase):
     # pylint: disable=protected-access
-    def setUp(self):
-        self.report_processor = MockLibraryReportProcessor()
-
     def test_all_attributes(self):
         clone_xml = etree.fromstring(
             fixture_clone_xml(
@@ -680,7 +723,7 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result,
@@ -688,23 +731,17 @@ class TestCloneStatusToDto(TestCase):
                 description="Test description", target_role=PCMK_ROLE_STARTED
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_primitive_member(self):
         clone_xml = etree.fromstring(
             fixture_clone_xml(instances=[fixture_primitive_xml()])
         )
 
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result, fixture_clone_dto(instances=[fixture_primitive_dto()])
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_primitive_member_multiple(self):
         clone_xml = etree.fromstring(
@@ -716,7 +753,7 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result,
@@ -727,9 +764,6 @@ class TestCloneStatusToDto(TestCase):
                 ]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_primitive_member_unique(self):
         clone_xml = etree.fromstring(
@@ -744,7 +778,7 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result,
@@ -756,9 +790,6 @@ class TestCloneStatusToDto(TestCase):
                 ],
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_primitive_member_promotable(self):
         clone_xml = etree.fromstring(
@@ -772,7 +803,7 @@ class TestCloneStatusToDto(TestCase):
                 ],
             )
         )
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result,
@@ -786,10 +817,31 @@ class TestCloneStatusToDto(TestCase):
                 ],
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
+
+    def test_primitive_member_invalid_role(self):
+        clone_xml = etree.fromstring(
+            fixture_clone_xml(
+                instances=[fixture_primitive_xml(role="NotPcmkRole")]
+            )
+        )
+
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._clone_to_dto(clone_xml)
+        self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
+
+    def test_primitive_member_invalid_target_role(self):
+        clone_xml = etree.fromstring(
+            fixture_clone_xml(
+                instances=[fixture_primitive_xml(target_role="NotPcmkRole")]
+            )
         )
 
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._clone_to_dto(clone_xml)
+        self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
+
     def test_primitive_member_different_ids(self):
         clone_xml = etree.fromstring(
             fixture_clone_xml(
@@ -802,18 +854,9 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        assert_raise_library_error(
-            lambda: status._clone_to_dto(self.report_processor, clone_xml)
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS,
-                    clone_id="resource-clone",
-                )
-            ],
-        )
+        with self.assertRaises(status.DifferentMemberIdsError) as cm:
+            status._clone_to_dto(clone_xml)
+        self.assertEqual(cm.exception.resource_id, "resource-clone")
 
     def test_group_member(self):
         clone_xml = etree.fromstring(
@@ -830,7 +873,7 @@ class TestCloneStatusToDto(TestCase):
                 ],
             )
         )
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result,
@@ -843,9 +886,6 @@ class TestCloneStatusToDto(TestCase):
                 ],
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_group_member_unique(self):
         clone_xml = etree.fromstring(
@@ -869,7 +909,7 @@ class TestCloneStatusToDto(TestCase):
                 ],
             )
         )
-        result = status._clone_to_dto(self.report_processor, clone_xml)
+        result = status._clone_to_dto(clone_xml)
 
         self.assertEqual(
             result,
@@ -883,9 +923,6 @@ class TestCloneStatusToDto(TestCase):
                 ],
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_group_member_different_group_ids(self):
         clone_xml = etree.fromstring(
@@ -903,18 +940,9 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        assert_raise_library_error(
-            lambda: status._clone_to_dto(self.report_processor, clone_xml)
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS,
-                    clone_id="resource-clone",
-                )
-            ],
-        )
+        with self.assertRaises(status.DifferentMemberIdsError) as cm:
+            status._clone_to_dto(clone_xml)
+        self.assertEqual(cm.exception.resource_id, "resource-clone")
 
     def test_group_member_different_primitive_ids(self):
         clone_xml = etree.fromstring(
@@ -937,18 +965,9 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        assert_raise_library_error(
-            lambda: status._clone_to_dto(self.report_processor, clone_xml)
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_CLONE_MEMBERS_DIFFERENT_IDS,
-                    clone_id="resource-clone",
-                )
-            ],
-        )
+        with self.assertRaises(status.DifferentMemberIdsError) as cm:
+            status._clone_to_dto(clone_xml)
+        self.assertEqual(cm.exception.resource_id, "resource-clone")
 
     def test_primitive_member_types_mixed(self):
         clone_xml = etree.fromstring(
@@ -965,18 +984,9 @@ class TestCloneStatusToDto(TestCase):
             )
         )
 
-        assert_raise_library_error(
-            lambda: status._clone_to_dto(self.report_processor, clone_xml)
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_CLONE_MIXED_MEMBERS,
-                    clone_id="resource-clone",
-                )
-            ],
-        )
+        with self.assertRaises(status.MixedMembersError) as cm:
+            status._clone_to_dto(clone_xml)
+        self.assertEqual(cm.exception.resource_id, "resource-clone")
 
     def test_invalid_member(self):
         resources = {
@@ -985,61 +995,43 @@ class TestCloneStatusToDto(TestCase):
         }
         for resource_id, element in resources.items():
             with self.subTest(value=resource_id):
-                self.setUp()
                 clone_xml = etree.fromstring(
-                    fixture_clone_xml(instances=[element])
-                )
-
-                # pylint: disable=cell-var-from-loop
-                assert_raise_library_error(
-                    lambda: status._clone_to_dto(
-                        self.report_processor, clone_xml
+                    fixture_clone_xml(
+                        resource_id="outer-clone", instances=[element]
                     )
                 )
-                assert_report_item_list_equal(
-                    self.report_processor.report_item_list,
-                    [
-                        fixture.error(
-                            reports.codes.CLUSTER_STATUS_UNEXPECTED_MEMBER,
-                            resource_id="resource-clone",
-                            resource_type="clone",
-                            member_id=resource_id,
-                            expected_types=["primitive", "group"],
-                        )
-                    ],
+
+                with self.assertRaises(status.UnexpectedMemberError) as cm:
+                    status._clone_to_dto(clone_xml)
+
+                self.assertEqual(cm.exception.resource_id, "outer-clone")
+                self.assertEqual(cm.exception.member_id, resource_id)
+                self.assertEqual(
+                    cm.exception.expected_types, ["primitive", "group"]
                 )
 
 
 class TestBundleReplicaStatusToDto(TestCase):
     # pylint: disable=protected-access
     def setUp(self):
-        self.report_processor = MockLibraryReportProcessor()
+        self.bundle_id = "resource-bundle"
+        self.bundle_type = "podman"
 
     def test_no_member_no_ip(self):
         replica_xml = etree.fromstring(fixture_replica_xml())
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
         result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
+            replica_xml, self.bundle_id, self.bundle_type
         )
         self.assertEqual(result, fixture_replica_dto())
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_no_member(self):
         replica_xml = etree.fromstring(fixture_replica_xml(ip=True))
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
         result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
+            replica_xml, self.bundle_id, self.bundle_type
         )
         self.assertEqual(result, fixture_replica_dto(ip=True))
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_member(self):
         replica_xml = etree.fromstring(
@@ -1051,10 +1043,8 @@ class TestBundleReplicaStatusToDto(TestCase):
             )
         )
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
         result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
+            replica_xml, self.bundle_id, self.bundle_type
         )
         self.assertEqual(
             result,
@@ -1063,9 +1053,6 @@ class TestBundleReplicaStatusToDto(TestCase):
                 member=fixture_primitive_dto(node_names=["resource-bundle-0"]),
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_member_no_ip(self):
         replica_xml = etree.fromstring(
@@ -1076,10 +1063,8 @@ class TestBundleReplicaStatusToDto(TestCase):
             )
         )
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
         result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
+            replica_xml, self.bundle_id, self.bundle_type
         )
         self.assertEqual(
             result,
@@ -1087,10 +1072,35 @@ class TestBundleReplicaStatusToDto(TestCase):
                 member=fixture_primitive_dto(node_names=["resource-bundle-0"])
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
+
+    def test_invalid_role(self):
+        replica_xml = etree.fromstring(
+            fixture_replica_xml(
+                member=fixture_primitive_xml(role="NotPcmkRole")
+            )
+        )
+
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
+            )
+        self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
+
+    def test_invalid_target_role(self):
+        replica_xml = etree.fromstring(
+            fixture_replica_xml(
+                member=fixture_primitive_xml(target_role="NotPcmkRole")
+            )
         )
 
+        with self.assertRaises(status.UnknownPcmkRoleError) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
+            )
+        self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
+
     def test_no_container(self):
         replica_xml = etree.fromstring(
             """
@@ -1108,44 +1118,28 @@ class TestBundleReplicaStatusToDto(TestCase):
           """
         )
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-        assert_raise_library_error(
-            lambda: status._replica_to_dto(
-                self.report_processor, replica_xml, bundle_id, bundle_type
+        with self.assertRaises(
+            status.BundleReplicaMissingImplicitResourceError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
             )
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER,
-                    bundle_id=bundle_id,
-                    replica_id="0",
-                )
-            ],
-        )
+        self.assertEqual(cm.exception.resource_id, self.bundle_id)
+        self.assertEqual(cm.exception.replica_id, "0")
+        self.assertEqual(cm.exception.implicit_type, "container")
 
     def test_empty_replica(self):
         replica_xml = etree.fromstring('<replica id="0" />')
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-        assert_raise_library_error(
-            lambda: status._replica_to_dto(
-                self.report_processor, replica_xml, bundle_id, bundle_type
+        with self.assertRaises(
+            status.BundleReplicaMissingImplicitResourceError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
             )
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_NO_CONTAINER,
-                    bundle_id=bundle_id,
-                    replica_id="0",
-                )
-            ],
-        )
+        self.assertEqual(cm.exception.resource_id, self.bundle_id)
+        self.assertEqual(cm.exception.replica_id, "0")
+        self.assertEqual(cm.exception.implicit_type, "container")
 
     def test_member_no_remote(self):
         replica_xml = etree.fromstring(
@@ -1159,23 +1153,15 @@ class TestBundleReplicaStatusToDto(TestCase):
             """
         )
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-        assert_raise_library_error(
-            lambda: status._replica_to_dto(
-                self.report_processor, replica_xml, bundle_id, bundle_type
+        with self.assertRaises(
+            status.BundleReplicaMissingImplicitResourceError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
             )
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_MISSING_REMOTE,
-                    bundle_id=bundle_id,
-                    replica_id="0",
-                )
-            ],
-        )
+        self.assertEqual(cm.exception.resource_id, self.bundle_id)
+        self.assertEqual(cm.exception.replica_id, "0")
+        self.assertEqual(cm.exception.implicit_type, "remote")
 
     def test_member_same_id_as_container(self):
         # xml taken from crm_mon output
@@ -1198,22 +1184,14 @@ class TestBundleReplicaStatusToDto(TestCase):
             </replica>
             """
         )
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-        result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
-        )
-        self.assertTrue(result is None)
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.warn(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
-                    bundle_id=bundle_id,
-                    bad_ids=["resource-bundle-podman-0"],
-                )
-            ],
-        )
+        with self.assertRaises(
+            status.BundleSameIdAsImplicitResourceError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
+            )
+        self.assertEqual(cm.exception.bundle_id, self.bundle_id)
+        self.assertEqual(cm.exception.bad_ids, ["resource-bundle-podman-0"])
 
     def test_member_same_id_as_remote(self):
         # xml taken from crm_mon output
@@ -1233,22 +1211,14 @@ class TestBundleReplicaStatusToDto(TestCase):
             </replica>
             """
         )
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-        result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
-        )
-        self.assertTrue(result is None)
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.warn(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
-                    bundle_id=bundle_id,
-                    bad_ids=["resource-bundle-0"],
-                )
-            ],
-        )
+        with self.assertRaises(
+            status.BundleSameIdAsImplicitResourceError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
+            )
+        self.assertEqual(cm.exception.bundle_id, self.bundle_id)
+        self.assertEqual(cm.exception.bad_ids, ["resource-bundle-0"])
 
     def test_member_same_id_as_ip(self):
         # xml taken from crm_mon output
@@ -1271,22 +1241,15 @@ class TestBundleReplicaStatusToDto(TestCase):
             </replica>
             """
         )
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-
-        result = status._replica_to_dto(
-            self.report_processor, replica_xml, bundle_id, bundle_type
-        )
-        self.assertTrue(result is None)
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.warn(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
-                    bundle_id=bundle_id,
-                    bad_ids=["resource-bundle-ip-192.168.122.250"],
-                )
-            ],
+        with self.assertRaises(
+            status.BundleSameIdAsImplicitResourceError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
+            )
+        self.assertEqual(cm.exception.bundle_id, self.bundle_id)
+        self.assertEqual(
+            cm.exception.bad_ids, ["resource-bundle-ip-192.168.122.250"]
         )
 
     def test_too_many_members(self):
@@ -1312,42 +1275,27 @@ class TestBundleReplicaStatusToDto(TestCase):
             """
         )
 
-        bundle_id = "resource-bundle"
-        bundle_type = "podman"
-        assert_raise_library_error(
-            lambda: status._replica_to_dto(
-                self.report_processor, replica_xml, bundle_id, bundle_type
+        with self.assertRaises(
+            status.BundleReplicaInvalidMemberCountError
+        ) as cm:
+            status._replica_to_dto(
+                replica_xml, self.bundle_id, self.bundle_type
             )
-        )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.error(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_REPLICA_INVALID_COUNT,
-                    bundle_id=bundle_id,
-                    replica_id="0",
-                )
-            ],
-        )
+        self.assertEqual(cm.exception.resource_id, self.bundle_id)
+        self.assertEqual(cm.exception.replica_id, "0")
 
 
 class TestBundleStatusToDto(TestCase):
     # pylint: disable=protected-access
-    def setUp(self):
-        self.report_processor = MockLibraryReportProcessor()
-
     def test_no_member(self):
         bundle_xml = etree.fromstring(
             fixture_bundle_xml(replicas=[fixture_replica_xml()])
         )
 
-        result = status._bundle_to_dto(self.report_processor, bundle_xml, False)
+        result = status._bundle_to_dto(bundle_xml, False)
         self.assertEqual(
             result, fixture_bundle_dto(replicas=[fixture_replica_dto()])
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_member(self):
         bundle_xml = etree.fromstring(
@@ -1362,7 +1310,7 @@ class TestBundleStatusToDto(TestCase):
                 ]
             )
         )
-        result = status._bundle_to_dto(self.report_processor, bundle_xml, False)
+        result = status._bundle_to_dto(bundle_xml, False)
         self.assertEqual(
             result,
             fixture_bundle_dto(
@@ -1376,9 +1324,6 @@ class TestBundleStatusToDto(TestCase):
                 ]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_multiple_replicas(self):
         bundle_xml = etree.fromstring(
@@ -1401,7 +1346,7 @@ class TestBundleStatusToDto(TestCase):
                 ]
             )
         )
-        result = status._bundle_to_dto(self.report_processor, bundle_xml, False)
+        result = status._bundle_to_dto(bundle_xml, False)
         self.assertEqual(
             result,
             fixture_bundle_dto(
@@ -1423,9 +1368,6 @@ class TestBundleStatusToDto(TestCase):
                 ]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
 
     def test_same_id_as_implicit(self):
         bundle_xml = etree.fromstring(
@@ -1447,18 +1389,13 @@ class TestBundleStatusToDto(TestCase):
             </bundle>
             """
         )
-        result = status._bundle_to_dto(self.report_processor, bundle_xml, False)
-        self.assertTrue(result is None)
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.warn(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
-                    bundle_id="resource-bundle",
-                    bad_ids=["resource-bundle-0"],
-                )
-            ],
-        )
+
+        with self.assertRaises(
+            status.BundleSameIdAsImplicitResourceError
+        ) as cm:
+            status._bundle_to_dto(bundle_xml, False)
+        self.assertEqual(cm.exception.bundle_id, "resource-bundle")
+        self.assertEqual(cm.exception.bad_ids, ["resource-bundle-0"])
 
     def test_same_id_as_implicit_multiple_replicas(self):
         bundle_xml = etree.fromstring(
@@ -1491,18 +1428,12 @@ class TestBundleStatusToDto(TestCase):
             </bundle>
             """
         )
-        result = status._bundle_to_dto(self.report_processor, bundle_xml, False)
-        self.assertTrue(result is None)
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list,
-            [
-                fixture.warn(
-                    reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
-                    bundle_id="resource-bundle",
-                    bad_ids=["resource-bundle-1"],
-                )
-            ],
-        )
+        with self.assertRaises(
+            status.BundleSameIdAsImplicitResourceError
+        ) as cm:
+            status._bundle_to_dto(bundle_xml, False)
+        self.assertEqual(cm.exception.bundle_id, "resource-bundle")
+        self.assertEqual(cm.exception.bad_ids, ["resource-bundle-1"])
 
     def test_replicas_different(self):
         replicas = {
@@ -1522,8 +1453,6 @@ class TestBundleStatusToDto(TestCase):
         }
         for name, element in replicas.items():
             with self.subTest(value=name):
-                self.setUp()
-
                 bundle_xml = etree.fromstring(
                     fixture_bundle_xml(
                         replicas=[
@@ -1537,47 +1466,29 @@ class TestBundleStatusToDto(TestCase):
                     )
                 )
 
-                # pylint: disable=cell-var-from-loop
-                assert_raise_library_error(
-                    lambda: status._bundle_to_dto(
-                        self.report_processor, bundle_xml
-                    )
-                )
-
-                assert_report_item_list_equal(
-                    self.report_processor.report_item_list,
-                    [
-                        fixture.error(
-                            reports.codes.CLUSTER_STATUS_BUNDLE_DIFFERENT_REPLICAS,
-                            bundle_id="resource-bundle",
-                        )
-                    ],
-                )
+                with self.assertRaises(status.BundleDifferentReplicas) as cm:
+                    status._bundle_to_dto(bundle_xml)
+                self.assertEqual(cm.exception.resource_id, "resource-bundle")
 
 
 class TestResourcesStatusToDto(TestCase):
-    def setUp(self):
-        self.report_processor = MockLibraryReportProcessor()
-
     def test_empty_resources(self):
         status_xml = etree.fromstring(fixture_crm_mon_xml([]))
 
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
         self.assertEqual(result, ResourcesStatusDto([]))
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
+        assert_report_item_list_equal(parser.get_warnings(), [])
 
     def test_single_primitive(self):
         status_xml = etree.fromstring(
             fixture_crm_mon_xml([fixture_primitive_xml()])
         )
 
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
         self.assertEqual(result, ResourcesStatusDto([fixture_primitive_dto()]))
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
+        assert_report_item_list_equal(parser.get_warnings(), [])
 
     def test_single_group(self):
         status_xml = etree.fromstring(
@@ -1586,16 +1497,15 @@ class TestResourcesStatusToDto(TestCase):
             )
         )
 
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
         self.assertEqual(
             result,
             ResourcesStatusDto(
                 [fixture_group_dto(members=[fixture_primitive_dto()])]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
+        assert_report_item_list_equal(parser.get_warnings(), [])
 
     def test_single_clone(self):
         status_xml = etree.fromstring(
@@ -1604,16 +1514,15 @@ class TestResourcesStatusToDto(TestCase):
             )
         )
 
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
         self.assertEqual(
             result,
             ResourcesStatusDto(
                 [fixture_clone_dto(instances=[fixture_primitive_dto()])]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
+        assert_report_item_list_equal(parser.get_warnings(), [])
 
     def test_single_bundle(self):
         status_xml = etree.fromstring(
@@ -1633,7 +1542,8 @@ class TestResourcesStatusToDto(TestCase):
             )
         )
 
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
         self.assertEqual(
             result,
             ResourcesStatusDto(
@@ -1651,9 +1561,7 @@ class TestResourcesStatusToDto(TestCase):
                 ]
             ),
         )
-        assert_report_item_list_equal(
-            self.report_processor.report_item_list, []
-        )
+        assert_report_item_list_equal(parser.get_warnings(), [])
 
     def test_all_resource_types(self):
         status_xml = etree.fromstring(
@@ -1675,30 +1583,30 @@ class TestResourcesStatusToDto(TestCase):
                 ]
             )
         )
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
 
-        self.assertEqual(result.resources[0], fixture_primitive_dto())
-        self.assertEqual(
-            result.resources[1],
-            fixture_group_dto(members=[fixture_primitive_dto()]),
-        )
         self.assertEqual(
-            result.resources[2],
-            fixture_clone_dto(instances=[fixture_primitive_dto()]),
-        )
-        self.assertEqual(
-            result.resources[3],
-            fixture_bundle_dto(
-                replicas=[
-                    fixture_replica_dto(
-                        ip=True,
-                        member=fixture_primitive_dto(
-                            node_names=["resource-bundle-0"]
-                        ),
-                    )
+            result,
+            ResourcesStatusDto(
+                [
+                    fixture_primitive_dto(),
+                    fixture_group_dto(members=[fixture_primitive_dto()]),
+                    fixture_clone_dto(instances=[fixture_primitive_dto()]),
+                    fixture_bundle_dto(
+                        replicas=[
+                            fixture_replica_dto(
+                                ip=True,
+                                member=fixture_primitive_dto(
+                                    node_names=["resource-bundle-0"]
+                                ),
+                            )
+                        ]
+                    ),
                 ]
             ),
         )
+        assert_report_item_list_equal(parser.get_warnings(), [])
 
     def test_skip_bundle(self):
         status_xml = etree.fromstring(
@@ -1726,11 +1634,12 @@ class TestResourcesStatusToDto(TestCase):
             )
         )
 
-        result = status.status_xml_to_dto(self.report_processor, status_xml)
+        parser = status.ClusterStatusParser(status_xml)
+        result = parser.status_xml_to_dto()
 
         self.assertEqual(result, ResourcesStatusDto([fixture_primitive_dto()]))
         assert_report_item_list_equal(
-            self.report_processor.report_item_list,
+            parser.get_warnings(),
             [
                 fixture.warn(
                     reports.codes.CLUSTER_STATUS_BUNDLE_MEMBER_ID_AS_IMPLICIT,
-- 
2.25.1

