From 7c56001aa76c4a5f69f29b328061c419c7ce856b Mon Sep 17 00:00:00 2001
From: Peter Romancik <promanci@redhat.com>
Date: Thu, 1 Feb 2024 17:17:40 +0100
Subject: [PATCH 1/2] further fixes after review

---
 pcs/common/reports/codes.py                   |  2 +-
 pcs/common/reports/messages.py                |  8 +-
 pcs/lib/pacemaker/status.py                   | 85 ++++++++++---------
 .../tier0/common/reports/test_messages.py     | 14 +--
 pcs_test/tier0/lib/commands/test_status.py    |  4 +-
 pcs_test/tier0/lib/pacemaker/test_status.py   | 68 ++++++++-------
 6 files changed, 97 insertions(+), 84 deletions(-)

diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py
index f9614331..e967d0b1 100644
--- a/pcs/common/reports/codes.py
+++ b/pcs/common/reports/codes.py
@@ -50,7 +50,7 @@ AGENT_SELF_VALIDATION_SKIPPED_UPDATED_RESOURCE_MISCONFIGURED = M(
 )
 AGENT_SELF_VALIDATION_RESULT = M("AGENT_SELF_VALIDATION_RESULT")
 BAD_CLUSTER_STATE_FORMAT = M("BAD_CLUSTER_STATE_FORMAT")
-BAD_CLUSTER_STATE = M("BAD_CLUSTER_STATE")
+BAD_CLUSTER_STATE_DATA = M("BAD_CLUSTER_STATE_DATA")
 BOOTH_ADDRESS_DUPLICATION = M("BOOTH_ADDRESS_DUPLICATION")
 BOOTH_ALREADY_IN_CIB = M("BOOTH_ALREADY_IN_CIB")
 BOOTH_AUTHFILE_NOT_USED = M("BOOTH_AUTHFILE_NOT_USED")
diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py
index 8b9bc63e..53f15170 100644
--- a/pcs/common/reports/messages.py
+++ b/pcs/common/reports/messages.py
@@ -3277,7 +3277,7 @@ class BadClusterStateFormat(ReportItemMessage):
 
 
 @dataclass(frozen=True)
-class BadClusterState(ReportItemMessage):
+class BadClusterStateData(ReportItemMessage):
     """
     crm_mon xml output is invalid despite conforming to the schema
 
@@ -3285,13 +3285,13 @@ class BadClusterState(ReportItemMessage):
     """
 
     reason: Optional[str] = None
-    _code = codes.BAD_CLUSTER_STATE
+    _code = codes.BAD_CLUSTER_STATE_DATA
 
     @property
     def message(self) -> str:
         return (
             "Cannot load cluster status, xml does not describe valid cluster "
-            f"status{format_optional(self.reason, template=': {}')}."
+            f"status{format_optional(self.reason, template=': {}')}"
         )
 
 
@@ -3314,7 +3314,7 @@ class ClusterStatusBundleMemberIdAsImplicit(ReportItemMessage):
         return (
             "Skipping bundle '{bundle_id}': {resource_word} "
             "{bad_ids} {has} the same id as some of the "
-            "implicit bundle resources."
+            "implicit bundle resources"
         ).format(
             bundle_id=self.bundle_id,
             resource_word=format_plural(self.bad_ids, "resource"),
diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py
index a86ede55..deb8aa0d 100644
--- a/pcs/lib/pacemaker/status.py
+++ b/pcs/lib/pacemaker/status.py
@@ -2,7 +2,6 @@ from collections import Counter
 from typing import (
     Optional,
     Sequence,
-    Union,
     cast,
 )
 
@@ -60,11 +59,13 @@ class UnexpectedMemberError(ClusterStatusParsingError):
         resource_id: str,
         resource_type: str,
         member_id: str,
+        member_type: str,
         expected_types: list[str],
     ):
         super().__init__(resource_id)
         self.resource_type = resource_type
         self.member_id = member_id
+        self.member_type = member_type
         self.expected_types = expected_types
 
 
@@ -106,46 +107,44 @@ def cluster_status_parsing_error_to_report(
 ) -> reports.ReportItem:
     reason = ""
     if isinstance(e, EmptyResourceIdError):
-        reason = "Resource with empty id."
+        reason = "Resource with an empty id"
     elif isinstance(e, EmptyNodeNameError):
         reason = (
-            f"Resource with id '{e.resource_id}' contains node with empty name."
+            f"Resource '{e.resource_id}' contains a node with an empty name"
         )
     elif isinstance(e, UnknownPcmkRoleError):
         reason = (
-            f"Resource with id '{e.resource_id}' contains unknown "
-            f"pcmk role '{e.role}'."
+            f"Resource '{e.resource_id}' contains an unknown "
+            f"role '{e.role}'"
         )
     elif isinstance(e, UnexpectedMemberError):
         reason = (
-            f"Unexpected resource '{e.member_id}' inside of resource "
-            f"'{e.resource_id}' of type '{e.resource_type}'. "
-            f"Only resources of type {format_list(e.expected_types, '|')} "
-            f"can be in {e.resource_type}."
+            f"Unexpected resource '{e.member_id}' of type '{e.member_type}' "
+            f"inside of resource '{e.resource_id}' of type '{e.resource_type}'."
+            f" Only resources of type {format_list(e.expected_types)} "
+            f"can be in a {e.resource_type}"
         )
 
     elif isinstance(e, MixedMembersError):
-        reason = (
-            f"Primitive and group members mixed in clone '{e.resource_id}'."
-        )
+        reason = f"Primitive and group members mixed in clone '{e.resource_id}'"
     elif isinstance(e, DifferentMemberIdsError):
-        reason = f"Members with different ids in resource '{e.resource_id}'."
+        reason = f"Members with different ids in clone '{e.resource_id}'"
     elif isinstance(e, BundleReplicaMissingImplicitResourceError):
         reason = (
             f"Replica '{e.replica_id}' of bundle '{e.resource_id}' "
-            f"is missing implicit {e.implicit_type} resource."
+            f"is missing implicit {e.implicit_type} resource"
         )
     elif isinstance(e, BundleReplicaInvalidMemberCountError):
         reason = (
             f"Replica '{e.replica_id}' of bundle '{e.resource_id}' has "
-            "invalid number of members."
+            "invalid number of members"
         )
     elif isinstance(e, BundleDifferentReplicas):
-        reason = f"Replicas of bundle '{e.resource_id}' are not the same."
+        reason = f"Replicas of bundle '{e.resource_id}' are not the same"
 
     return reports.ReportItem(
         reports.ReportItemSeverity.error(),
-        reports.messages.BadClusterState(reason),
+        reports.messages.BadClusterStateData(reason),
     )
 
 
@@ -160,7 +159,7 @@ def _primitive_to_dto(
     target_role = _get_target_role(primitive_el)
 
     node_names = [
-        str(node.get("name")) for node in primitive_el.iterfind("node")
+        str(node.attrib["name"]) for node in primitive_el.iterfind("node")
     ]
 
     if node_names and any(not name for name in node_names):
@@ -168,7 +167,7 @@ def _primitive_to_dto(
 
     return PrimitiveStatusDto(
         resource_id,
-        str(primitive_el.get("resource_agent")),
+        str(primitive_el.attrib["resource_agent"]),
         role,
         target_role,
         is_true(primitive_el.get("active", "false")),
@@ -179,7 +178,7 @@ def _primitive_to_dto(
         is_true(primitive_el.get("failed", "false")),
         is_true(primitive_el.get("managed", "false")),
         is_true(primitive_el.get("failure_ignored", "false")),
-        [str(node.get("name")) for node in primitive_el.iterfind("node")],
+        node_names,
         primitive_el.get("pending"),
         primitive_el.get("locked_to"),
     )
@@ -197,7 +196,11 @@ def _group_to_dto(
             member_list.append(_primitive_to_dto(member, remove_clone_suffix))
         else:
             raise UnexpectedMemberError(
-                group_id, "group", str(member.get("id")), ["primitive"]
+                group_id,
+                "group",
+                str(member.attrib["id"]),
+                member.tag,
+                ["primitive"],
             )
 
     return GroupStatusDto(
@@ -228,29 +231,28 @@ def _clone_to_dto(
             group_list.append(_group_to_dto(member, is_unique))
         else:
             raise UnexpectedMemberError(
-                clone_id, "clone", str(member.get("id")), ["primitive", "group"]
+                clone_id,
+                "clone",
+                str(member.attrib["id"]),
+                member.tag,
+                ["primitive", "group"],
             )
 
     if primitive_list and group_list:
         raise MixedMembersError(clone_id)
 
-    instance_list: Union[list[PrimitiveStatusDto], list[GroupStatusDto]]
     if primitive_list:
         if len(set(res.resource_id for res in primitive_list)) > 1:
             raise DifferentMemberIdsError(clone_id)
-        instance_list = primitive_list
-    else:
+    if group_list:
         group_ids = set(group.resource_id for group in group_list)
         children_ids = set(
             tuple(child.resource_id for child in group.members)
             for group in group_list
         )
-
         if len(group_ids) > 1 or len(children_ids) > 1:
             raise DifferentMemberIdsError(clone_id)
 
-        instance_list = group_list
-
     return CloneStatusDto(
         clone_id,
         is_true(clone_el.get("multi_state", "false")),
@@ -262,7 +264,7 @@ def _clone_to_dto(
         is_true(clone_el.get("failed", "false")),
         is_true(clone_el.get("failure_ignored", "false")),
         target_role,
-        instance_list,
+        primitive_list or group_list,
     )
 
 
@@ -270,7 +272,7 @@ def _bundle_to_dto(
     bundle_el: _Element, _remove_clone_suffix: bool = False
 ) -> BundleStatusDto:
     bundle_id = _get_resource_id(bundle_el)
-    bundle_type = str(bundle_el.get("type"))
+    bundle_type = str(bundle_el.attrib["type"])
 
     replica_list = [
         _replica_to_dto(replica, bundle_id, bundle_type)
@@ -283,7 +285,7 @@ def _bundle_to_dto(
     return BundleStatusDto(
         bundle_id,
         bundle_type,
-        str(bundle_el.get("image")),
+        str(bundle_el.attrib["image"]),
         is_true(bundle_el.get("unique", "false")),
         is_true(bundle_el.get("maintenance", "false")),
         bundle_el.get("description"),
@@ -302,17 +304,18 @@ class ClusterStatusParser:
     }
 
     def __init__(self, status: _Element):
-        self.status = status
-        self.warnings: reports.ReportItemList = []
+        """
+        status -- xml element from crm_mon xml, validated using the appropriate
+            rng schema
+        """
+        self._status = status
+        self._warnings: reports.ReportItemList = []
 
     def status_xml_to_dto(self) -> ResourcesStatusDto:
         """
         Return dto containing status of configured resources in the cluster
-
-        status -- status xml document from crm_mon, validated using
-            the appropriate rng schema
         """
-        resource_list = cast(list[_Element], self.status.xpath("resources/*"))
+        resource_list = cast(list[_Element], self._status.xpath("resources/*"))
 
         resource_dto_list = []
         for resource in resource_list:
@@ -328,7 +331,7 @@ class ClusterStatusParser:
                 # the implicitly created resource.
                 # We only skip such bundles while still providing status of the
                 # other resources.
-                self.warnings.append(
+                self._warnings.append(
                     reports.ReportItem.warning(
                         reports.messages.ClusterStatusBundleMemberIdAsImplicit(
                             e.bundle_id, e.bad_ids
@@ -339,11 +342,11 @@ class ClusterStatusParser:
         return ResourcesStatusDto(resource_dto_list)
 
     def get_warnings(self) -> reports.ReportItemList:
-        return self.warnings
+        return self._warnings
 
 
 def _get_resource_id(resource: _Element) -> str:
-    resource_id = resource.get("id")
+    resource_id = resource.attrib["id"]
     if not resource_id:
         raise EmptyResourceIdError()
     return str(resource_id)
@@ -374,7 +377,7 @@ def _remove_clone_suffix(resource_id: str) -> str:
 def _replica_to_dto(
     replica_el: _Element, bundle_id: str, bundle_type: str
 ) -> BundleReplicaStatusDto:
-    replica_id = str(replica_el.get("id"))
+    replica_id = str(replica_el.attrib["id"])
 
     resource_list = [
         _primitive_to_dto(resource)
diff --git a/pcs_test/tier0/common/reports/test_messages.py b/pcs_test/tier0/common/reports/test_messages.py
index 48eb730c..0ca95920 100644
--- a/pcs_test/tier0/common/reports/test_messages.py
+++ b/pcs_test/tier0/common/reports/test_messages.py
@@ -2195,23 +2195,23 @@ class BadClusterStateFormat(NameBuildTest):
         )
 
 
-class BadClusterState(NameBuildTest):
+class BadClusterStateData(NameBuildTest):
     def test_no_reason(self):
         self.assert_message_from_report(
             (
                 "Cannot load cluster status, xml does not describe "
-                "valid cluster status."
+                "valid cluster status"
             ),
-            reports.BadClusterState(),
+            reports.BadClusterStateData(),
         )
 
     def test_reason(self):
         self.assert_message_from_report(
             (
                 "Cannot load cluster status, xml does not describe "
-                "valid cluster status: sample reason."
+                "valid cluster status: sample reason"
             ),
-            reports.BadClusterState("sample reason"),
+            reports.BadClusterStateData("sample reason"),
         )
 
 
@@ -5843,7 +5843,7 @@ class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest):
         self.assert_message_from_report(
             (
                 "Skipping bundle 'resource-bundle': resource 'resource' has "
-                "the same id as some of the implicit bundle resources."
+                "the same id as some of the implicit bundle resources"
             ),
             reports.ClusterStatusBundleMemberIdAsImplicit(
                 "resource-bundle", ["resource"]
@@ -5855,7 +5855,7 @@ class ClusterStatusBundleMemberIdAsImplicit(NameBuildTest):
             (
                 "Skipping bundle 'resource-bundle': resources 'resource-0', "
                 "'resource-1' have the same id as some of the implicit bundle "
-                "resources."
+                "resources"
             ),
             reports.ClusterStatusBundleMemberIdAsImplicit(
                 "resource-bundle", ["resource-0", "resource-1"]
diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py
index 3b6b7665..b12e9531 100644
--- a/pcs_test/tier0/lib/commands/test_status.py
+++ b/pcs_test/tier0/lib/commands/test_status.py
@@ -1342,8 +1342,8 @@ class ResourcesStatus(TestCase):
             lambda: status.resources_status(self.env_assist.get_env()),
             [
                 fixture.error(
-                    report_codes.BAD_CLUSTER_STATE,
-                    reason="Resource with id 'R7' contains unknown pcmk role 'NotPcmkRole'.",
+                    report_codes.BAD_CLUSTER_STATE_DATA,
+                    reason="Resource 'R7' contains an unknown role 'NotPcmkRole'",
                 ),
             ],
             False,
diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py
index 778e97a6..ced1a47e 100644
--- a/pcs_test/tier0/lib/pacemaker/test_status.py
+++ b/pcs_test/tier0/lib/pacemaker/test_status.py
@@ -12,6 +12,7 @@ from pcs.common import reports
 from pcs.common.const import (
     PCMK_ROLE_STARTED,
     PCMK_ROLES,
+    PCMK_STATUS_ROLE_PROMOTED,
     PCMK_STATUS_ROLE_STARTED,
     PCMK_STATUS_ROLE_STOPPED,
     PCMK_STATUS_ROLE_UNPROMOTED,
@@ -334,8 +335,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Resource with empty id.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Resource with an empty id",
             ),
         )
 
@@ -346,8 +347,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Resource with id 'resource' contains node with empty name.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Resource 'resource' contains a node with an empty name",
             ),
         )
 
@@ -358,25 +359,25 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Resource with id 'resource' contains unknown pcmk role 'NotPcmkRole'.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Resource 'resource' contains an unknown role 'NotPcmkRole'",
             ),
         )
 
     def test_unexpected_member_group(self):
         report = status.cluster_status_parsing_error_to_report(
             status.UnexpectedMemberError(
-                "resource", "group", "member", ["primitive"]
+                "resource", "group", "member", "bundle", ["primitive"]
             )
         )
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
+                reports.codes.BAD_CLUSTER_STATE_DATA,
                 reason=(
-                    "Unexpected resource 'member' inside of resource "
-                    "'resource' of type 'group'. Only resources of type "
-                    "'primitive' can be in group."
+                    "Unexpected resource 'member' of type 'bundle' inside of "
+                    "resource 'resource' of type 'group'. Only resources of "
+                    "type 'primitive' can be in a group"
                 ),
             ),
         )
@@ -384,17 +385,17 @@ class TestParsingErrorToReport(TestCase):
     def test_unexpected_member_clone(self):
         report = status.cluster_status_parsing_error_to_report(
             status.UnexpectedMemberError(
-                "resource", "clone", "member", ["primitive", "group"]
+                "resource", "clone", "member", "bundle", ["primitive", "group"]
             )
         )
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
+                reports.codes.BAD_CLUSTER_STATE_DATA,
                 reason=(
-                    "Unexpected resource 'member' inside of resource "
-                    "'resource' of type 'clone'. Only resources of type "
-                    "'group'|'primitive' can be in clone."
+                    "Unexpected resource 'member' of type 'bundle' inside of "
+                    "resource 'resource' of type 'clone'. Only resources of "
+                    "type 'group', 'primitive' can be in a clone"
                 ),
             ),
         )
@@ -406,8 +407,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Primitive and group members mixed in clone 'resource'.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Primitive and group members mixed in clone 'resource'",
             ),
         )
 
@@ -418,8 +419,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Members with different ids in resource 'resource'.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Members with different ids in clone 'resource'",
             ),
         )
 
@@ -432,8 +433,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Replica '0' of bundle 'resource' is missing implicit container resource.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Replica '0' of bundle 'resource' is missing implicit container resource",
             ),
         )
 
@@ -444,8 +445,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Replica '0' of bundle 'resource' has invalid number of members.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Replica '0' of bundle 'resource' has invalid number of members",
             ),
         )
 
@@ -456,8 +457,8 @@ class TestParsingErrorToReport(TestCase):
         assert_report_item_equal(
             report,
             fixture.error(
-                reports.codes.BAD_CLUSTER_STATE,
-                reason="Replicas of bundle 'resource' are not the same.",
+                reports.codes.BAD_CLUSTER_STATE_DATA,
+                reason="Replicas of bundle 'resource' are not the same",
             ),
         )
 
@@ -549,6 +550,7 @@ class TestPrimitiveStatusToDto(TestCase):
         with self.assertRaises(status.UnknownPcmkRoleError) as cm:
             status._primitive_to_dto(primitive_xml)
         self.assertEqual(cm.exception.resource_id, "resource")
+        self.assertEqual(cm.exception.role, "NotPcmkRole")
 
     def test_target_role(self):
         for role in PCMK_ROLES:
@@ -573,6 +575,7 @@ class TestPrimitiveStatusToDto(TestCase):
                 with self.assertRaises(status.UnknownPcmkRoleError) as cm:
                     status._primitive_to_dto(primitive_xml)
                 self.assertEqual(cm.exception.resource_id, "resource")
+                self.assertEqual(cm.exception.role, value)
 
 
 class TestGroupStatusToDto(TestCase):
@@ -695,7 +698,11 @@ class TestGroupStatusToDto(TestCase):
                 with self.assertRaises(status.UnexpectedMemberError) as cm:
                     status._group_to_dto(group_xml)
                 self.assertEqual(cm.exception.resource_id, "outer-group")
+                self.assertEqual(cm.exception.resource_type, "group")
                 self.assertEqual(cm.exception.member_id, resource_id)
+                self.assertEqual(
+                    cm.exception.member_type, resource_id.split("-")[1]
+                )
                 self.assertEqual(cm.exception.expected_types, ["primitive"])
 
     def test_remove_clone_suffix(self):
@@ -796,7 +803,7 @@ class TestCloneStatusToDto(TestCase):
             fixture_clone_xml(
                 multi_state=True,
                 instances=[
-                    fixture_primitive_xml(role=PCMK_STATUS_ROLE_UNPROMOTED),
+                    fixture_primitive_xml(role=PCMK_STATUS_ROLE_PROMOTED),
                     fixture_primitive_xml(
                         role=PCMK_STATUS_ROLE_UNPROMOTED, node_names=["node2"]
                     ),
@@ -810,7 +817,7 @@ class TestCloneStatusToDto(TestCase):
             fixture_clone_dto(
                 multi_state=True,
                 instances=[
-                    fixture_primitive_dto(role=PCMK_STATUS_ROLE_UNPROMOTED),
+                    fixture_primitive_dto(role=PCMK_STATUS_ROLE_PROMOTED),
                     fixture_primitive_dto(
                         role=PCMK_STATUS_ROLE_UNPROMOTED, node_names=["node2"]
                     ),
@@ -1003,9 +1010,12 @@ class TestCloneStatusToDto(TestCase):
 
                 with self.assertRaises(status.UnexpectedMemberError) as cm:
                     status._clone_to_dto(clone_xml)
-
                 self.assertEqual(cm.exception.resource_id, "outer-clone")
+                self.assertEqual(cm.exception.resource_type, "clone")
                 self.assertEqual(cm.exception.member_id, resource_id)
+                self.assertEqual(
+                    cm.exception.member_type, resource_id.split("-")[1]
+                )
                 self.assertEqual(
                     cm.exception.expected_types, ["primitive", "group"]
                 )
-- 
2.25.1

From c32249a39ef262e3f2106eb8ca01b6efb8e74707 Mon Sep 17 00:00:00 2001
From: Peter Romancik <promanci@redhat.com>
Date: Thu, 1 Feb 2024 17:45:20 +0100
Subject: [PATCH 2/2] store clone instance id in resource status dtos

---
 pcs/common/status_dto.py                    |  2 ++
 pcs/lib/pacemaker/status.py                 | 19 +++++++----
 pcs_test/tier0/lib/commands/test_status.py  |  3 ++
 pcs_test/tier0/lib/pacemaker/test_status.py | 36 ++++++++++++++++-----
 4 files changed, 46 insertions(+), 14 deletions(-)

diff --git a/pcs/common/status_dto.py b/pcs/common/status_dto.py
index dcc94eca..240ff930 100644
--- a/pcs/common/status_dto.py
+++ b/pcs/common/status_dto.py
@@ -16,6 +16,7 @@ from pcs.common.interface.dto import DataTransferObject
 class PrimitiveStatusDto(DataTransferObject):
     # pylint: disable=too-many-instance-attributes
     resource_id: str
+    clone_instance_id: Optional[str]
     resource_agent: str
     role: PcmkStatusRoleType
     target_role: Optional[PcmkRoleType]
@@ -35,6 +36,7 @@ class PrimitiveStatusDto(DataTransferObject):
 @dataclass(frozen=True)
 class GroupStatusDto(DataTransferObject):
     resource_id: str
+    clone_instance_id: Optional[str]
     maintenance: bool
     description: Optional[str]
     managed: bool
diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py
index deb8aa0d..6b37d6cb 100644
--- a/pcs/lib/pacemaker/status.py
+++ b/pcs/lib/pacemaker/status.py
@@ -152,8 +152,9 @@ def _primitive_to_dto(
     primitive_el: _Element, remove_clone_suffix: bool = False
 ) -> PrimitiveStatusDto:
     resource_id = _get_resource_id(primitive_el)
+    clone_suffix = None
     if remove_clone_suffix:
-        resource_id = _remove_clone_suffix(resource_id)
+        resource_id, clone_suffix = _remove_clone_suffix(resource_id)
 
     role = _get_role(primitive_el)
     target_role = _get_target_role(primitive_el)
@@ -167,6 +168,7 @@ def _primitive_to_dto(
 
     return PrimitiveStatusDto(
         resource_id,
+        clone_suffix,
         str(primitive_el.attrib["resource_agent"]),
         role,
         target_role,
@@ -187,8 +189,11 @@ def _primitive_to_dto(
 def _group_to_dto(
     group_el: _Element, remove_clone_suffix: bool = False
 ) -> GroupStatusDto:
-    # clone suffix is added even when the clone is non unique
-    group_id = _remove_clone_suffix(_get_resource_id(group_el))
+    # clone instance id present even when the clone is non unique
+    group_id, clone_instance_id = _remove_clone_suffix(
+        _get_resource_id(group_el)
+    )
+
     member_list = []
 
     for member in group_el:
@@ -205,6 +210,7 @@ def _group_to_dto(
 
     return GroupStatusDto(
         group_id,
+        clone_instance_id,
         is_true(group_el.get("maintenance", "false")),
         group_el.get("description"),
         is_true(group_el.get("managed", "false")),
@@ -368,10 +374,11 @@ def _get_target_role(resource: _Element) -> Optional[PcmkRoleType]:
     return PcmkRoleType(target_role)
 
 
-def _remove_clone_suffix(resource_id: str) -> str:
+def _remove_clone_suffix(resource_id: str) -> tuple[str, Optional[str]]:
     if ":" in resource_id:
-        return resource_id.rsplit(":", 1)[0]
-    return resource_id
+        resource_id, clone_suffix = resource_id.rsplit(":", 1)
+        return resource_id, clone_suffix
+    return resource_id, None
 
 
 def _replica_to_dto(
diff --git a/pcs_test/tier0/lib/commands/test_status.py b/pcs_test/tier0/lib/commands/test_status.py
index b12e9531..c7c808a3 100644
--- a/pcs_test/tier0/lib/commands/test_status.py
+++ b/pcs_test/tier0/lib/commands/test_status.py
@@ -1280,6 +1280,7 @@ def _fixture_primitive_resource_dto(
 ) -> PrimitiveStatusDto:
     return PrimitiveStatusDto(
         resource_id=resource_id,
+        clone_instance_id=None,
         resource_agent=resource_agent,
         role=PCMK_STATUS_ROLE_STOPPED,
         target_role=target_role,
@@ -1448,6 +1449,7 @@ class ResourcesStatus(TestCase):
                     ),
                     GroupStatusDto(
                         resource_id="G2",
+                        clone_instance_id=None,
                         maintenance=False,
                         description=None,
                         managed=True,
@@ -1475,6 +1477,7 @@ class ResourcesStatus(TestCase):
                         instances=[
                             GroupStatusDto(
                                 resource_id="G1",
+                                clone_instance_id="0",
                                 maintenance=False,
                                 description=None,
                                 managed=True,
diff --git a/pcs_test/tier0/lib/pacemaker/test_status.py b/pcs_test/tier0/lib/pacemaker/test_status.py
index ced1a47e..a852d45b 100644
--- a/pcs_test/tier0/lib/pacemaker/test_status.py
+++ b/pcs_test/tier0/lib/pacemaker/test_status.py
@@ -85,6 +85,7 @@ def fixture_primitive_xml(
 
 def fixture_primitive_dto(
     resource_id: str = "resource",
+    clone_instance_id: Optional[str] = None,
     resource_agent: str = "ocf:heartbeat:Dummy",
     role: PcmkStatusRoleType = PCMK_STATUS_ROLE_STARTED,
     target_role: Optional[str] = None,
@@ -94,6 +95,7 @@ def fixture_primitive_dto(
 ) -> PrimitiveStatusDto:
     return PrimitiveStatusDto(
         resource_id,
+        clone_instance_id,
         resource_agent,
         role,
         target_role,
@@ -136,11 +138,13 @@ def fixture_group_xml(
 
 def fixture_group_dto(
     resource_id: str = "resource-group",
+    clone_instance_id: Optional[str] = None,
     description: Optional[str] = None,
     members: Sequence[PrimitiveStatusDto] = (),
 ) -> GroupStatusDto:
     return GroupStatusDto(
         resource_id,
+        clone_instance_id,
         maintenance=False,
         description=description,
         managed=True,
@@ -506,7 +510,7 @@ class TestPrimitiveStatusToDto(TestCase):
 
         result = status._primitive_to_dto(primitive_xml, True)
 
-        self.assertEqual(result, fixture_primitive_dto())
+        self.assertEqual(result, fixture_primitive_dto(clone_instance_id="0"))
 
     def test_running_on_multiple_nodes(self):
         primitive_xml = etree.fromstring(
@@ -716,7 +720,10 @@ class TestGroupStatusToDto(TestCase):
         result = status._group_to_dto(group_xml, True)
         self.assertEqual(
             result,
-            fixture_group_dto(members=[fixture_primitive_dto()]),
+            fixture_group_dto(
+                clone_instance_id="0",
+                members=[fixture_primitive_dto(clone_instance_id="0")],
+            ),
         )
 
 
@@ -792,8 +799,10 @@ class TestCloneStatusToDto(TestCase):
             fixture_clone_dto(
                 unique=True,
                 instances=[
-                    fixture_primitive_dto(),
-                    fixture_primitive_dto(node_names=["node2"]),
+                    fixture_primitive_dto(clone_instance_id="0"),
+                    fixture_primitive_dto(
+                        clone_instance_id="1", node_names=["node2"]
+                    ),
                 ],
             ),
         )
@@ -886,9 +895,12 @@ class TestCloneStatusToDto(TestCase):
             result,
             fixture_clone_dto(
                 instances=[
-                    fixture_group_dto(members=[fixture_primitive_dto()]),
                     fixture_group_dto(
-                        members=[fixture_primitive_dto(node_names=["node2"])]
+                        clone_instance_id="0", members=[fixture_primitive_dto()]
+                    ),
+                    fixture_group_dto(
+                        clone_instance_id="1",
+                        members=[fixture_primitive_dto(node_names=["node2"])],
                     ),
                 ],
             ),
@@ -923,9 +935,17 @@ class TestCloneStatusToDto(TestCase):
             fixture_clone_dto(
                 unique=True,
                 instances=[
-                    fixture_group_dto(members=[fixture_primitive_dto()]),
                     fixture_group_dto(
-                        members=[fixture_primitive_dto(node_names=["node2"])]
+                        clone_instance_id="0",
+                        members=[fixture_primitive_dto(clone_instance_id="0")],
+                    ),
+                    fixture_group_dto(
+                        clone_instance_id="1",
+                        members=[
+                            fixture_primitive_dto(
+                                clone_instance_id="1", node_names=["node2"]
+                            )
+                        ],
                     ),
                 ],
             ),
-- 
2.25.1

