]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
python-common/rgw: fix get_realm_tokens returning empty result for realms with a... 70214/head
authorAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Wed, 15 Jul 2026 09:24:27 +0000 (14:54 +0530)
committerAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Tue, 21 Jul 2026 05:03:08 +0000 (10:33 +0530)
Issue: The Export Multi-site Realm Token feature in the Dashboard showed no token and no realm details for any realm that had a secondary zone configured (i.e. the realm had been replicated to another cluster). The API endpoint GET /api/rgw/realm/get_realm_tokens returned an empty list or raised a 500 Internal Server Error.

Fixes: https://tracker.ceph.com/issues/78232
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
src/python-common/ceph/rgw/rgwam_core.py
src/python-common/ceph/tests/test_rgwam_core.py [new file with mode: 0644]

index 20537e49b7e61d22d14beddf2d6167d57332ea31..2e0a3bdee31df496d816052e989a3ee967efc290 100644 (file)
@@ -818,46 +818,81 @@ class RGWAM:
 
         return (0, f'Modified zonegroup {zonegroup_name} of realm {realm_name}', '')
 
+    def _get_master_zone_ep_from_period(self, realm_period):
+        """Extract the master zone endpoint list directly from an already-fetched period dict."""
+        for zg in realm_period.get('period_map', {}).get('zonegroups', []):
+            if not bool(zg.get('is_master')):
+                continue
+            for zone in zg.get('zones', []):
+                if zone['id'] == zg['master_zone']:
+                    return zone.get('endpoints', [])
+        return []
+
+    def _get_realm_zone_ids(self, realm_period):
+        """Return the set of all zone IDs that belong to this realm's period."""
+        zone_ids = set()
+        for zg in realm_period.get('period_map', {}).get('zonegroups', []):
+            for zone in zg.get('zones', []):
+                zone_ids.add(zone['id'])
+        return zone_ids
+
     def get_realms_info(self):
         realms_info = []
         for realm_name in self.realm_op().list():
             realm = self.get_realm(realm_name)
             realm_period = self.period_op().get(realm)
             master_zone_id = realm_period['master_zone']
-            master_zone_name = self.get_master_zone_name(realm_period, master_zone_id)
-            local_zone_list = self.zone_op().list()
-
-            # Only consider the realm if master_zone_name is in the local zone list
-            if master_zone_name in local_zone_list:
-                master_zone_inf = self.zone_op().get(EntityID(master_zone_id))
-                zone_ep = self.period_op().get_master_zone_ep(realm)
-
-                if master_zone_inf and 'system_key' in master_zone_inf:
-                    access_key = master_zone_inf['system_key']['access_key']
-                    secret = master_zone_inf['system_key']['secret_key']
-                else:
-                    access_key = ''
-                    secret = ''
-
-                realms_info.append({
-                    "realm_name": realm_name,
-                    "realm_id": realm.id,
-                    "master_zone_id": master_zone_inf['id'] if master_zone_inf else '',
-                    "endpoint": zone_ep[0] if zone_ep else None,
-                    "access_key": access_key,
-                    "secret": secret
-                })
 
-        return realms_info
+            # Extract master zone endpoint directly from the already-fetched period map
+            zone_ep = self._get_master_zone_ep_from_period(realm_period)
+
+            # system_key (access_key / secret) lives in RGWZoneParams — a separate RADOS
+            # object stored per zone.  On a primary site the master zone's params exist
+            # locally, so 'zone get --zone-id=<master_zone_id>' succeeds and returns the
+            # system_key directly.
+            #
+            # On a secondary site only the secondary zone's params are stored locally;
+            # querying the master zone by ID raises ENOENT.  However, the secondary zone
+            # was created with the same access_key/secret from the realm token,
+            # so any locally-present zone for this realm carries the
+            # correct credentials.  We therefore fall back to fetching the first local
+            # zone for *this realm* when the master zone lookup fails.
+            # The realm_zone_ids set (built from the period) is used to restrict the
+            # search to zones that actually belong to the current realm, preventing
+            # credential cross-contamination on multi-realm hosts.
+            try:
+                zone_inf = self.zone_op().get(EntityID(master_zone_id))
+            except RGWAMCmdRunException:
+                # Master zone params not present locally — try any locally-stored zone
+                # that belongs to this realm (secondary site scenario).
+                realm_zone_ids = self._get_realm_zone_ids(realm_period)
+                zone_inf = None
+                for local_zone_name in self.zone_op().list():
+                    try:
+                        candidate = self.zone_op().get(EntityName(local_zone_name))
+                        if candidate.get('id') in realm_zone_ids:
+                            zone_inf = candidate
+                            break
+                    except RGWAMCmdRunException:
+                        continue
+
+            if zone_inf and 'system_key' in zone_inf:
+                access_key = zone_inf['system_key']['access_key']
+                secret = zone_inf['system_key']['secret_key']
+            else:
+                access_key = ''
+                secret = ''
+
+            realms_info.append({
+                "realm_name": realm_name,
+                "realm_id": realm.id,
+                "master_zone_id": master_zone_id,
+                "endpoint": zone_ep[0] if zone_ep else None,
+                "access_key": access_key,
+                "secret": secret
+            })
 
-    def get_master_zone_name(self, realm_data, master_zone_id):
-        # Find the zonegroups in the period_map
-        zonegroups = realm_data.get('period_map', {}).get('zonegroups', [])
-        for zonegroup in zonegroups:
-            for zone in zonegroup.get('zones', []):
-                if zone.get('id') == master_zone_id:
-                    return zone.get('name')
-        return None
+        return realms_info
 
     def zone_create(self, rgw_spec, start_radosgw, secondary_zone_period_retry_limit=5,
                     tier_type=None):
diff --git a/src/python-common/ceph/tests/test_rgwam_core.py b/src/python-common/ceph/tests/test_rgwam_core.py
new file mode 100644 (file)
index 0000000..f6658c2
--- /dev/null
@@ -0,0 +1,396 @@
+# -*- mode:python -*-
+# vim: ts=4 sw=4 expandtab
+
+from unittest.mock import MagicMock
+
+from ceph.rgw.rgwam_core import RGWAM, EnvArgs, EntityKey, EntityID
+from ceph.rgw.types import RGWAMCmdRunException
+
+
+# ---------------------------------------------------------------------------
+# Shared helpers / fixtures
+# ---------------------------------------------------------------------------
+
+def _make_period(master_zone_id, master_zonegroup_id,
+                 zonegroups=None, realm_id='realm-id-1'):
+    """Build a minimal period dict as radosgw-admin would return it."""
+    if zonegroups is None:
+        zonegroups = []
+    return {
+        'id': 'period-id-1',
+        'epoch': 1,
+        'master_zone': master_zone_id,
+        'master_zonegroup': master_zonegroup_id,
+        'realm_id': realm_id,
+        'period_map': {
+            'zonegroups': zonegroups,
+        },
+    }
+
+
+def _make_zonegroup(zg_id, zg_name, master_zone_id, zones, is_master=True):
+    """Build a minimal zonegroup entry inside a period map."""
+    return {
+        'id': zg_id,
+        'name': zg_name,
+        'api_name': zg_name,
+        'is_master': is_master,
+        'master_zone': master_zone_id,
+        'endpoints': [],
+        'zones': zones,
+    }
+
+
+def _make_zone_entry(zone_id, zone_name, endpoints=None):
+    """Build a minimal zone entry inside a zonegroup."""
+    return {
+        'id': zone_id,
+        'name': zone_name,
+        'endpoints': endpoints or [],
+    }
+
+
+def _zone_params(zone_id, zone_name, access_key='AK', secret='SK'):
+    """Build a minimal zone params dict (returned by 'zone get')."""
+    return {
+        'id': zone_id,
+        'name': zone_name,
+        'system_key': {
+            'access_key': access_key,
+            'secret_key': secret,
+        },
+    }
+
+
+def _make_rgwam():
+    """Return an RGWAM instance backed by a MagicMock manager."""
+    env = EnvArgs(MagicMock())
+    return RGWAM(env)
+
+
+# ---------------------------------------------------------------------------
+# Tests for _get_master_zone_ep_from_period
+# ---------------------------------------------------------------------------
+
+class TestGetMasterZoneEpFromPeriod:
+    """Unit tests for RGWAM._get_master_zone_ep_from_period."""
+
+    def test_returns_endpoints_for_master_zone(self):
+        """When the period has a master zonegroup with a master zone that has
+        endpoints, those endpoints are returned."""
+        period = _make_period(
+            master_zone_id='zone-a',
+            master_zonegroup_id='zg-1',
+            zonegroups=[
+                _make_zonegroup(
+                    'zg-1', 'default', 'zone-a',
+                    zones=[
+                        _make_zone_entry('zone-a', 'zone-a',
+                                         endpoints=['http://host1:8080',
+                                                    'http://host2:8080']),
+                    ],
+                    is_master=True,
+                )
+            ],
+        )
+        rgwam = _make_rgwam()
+        eps = rgwam._get_master_zone_ep_from_period(period)
+        assert eps == ['http://host1:8080', 'http://host2:8080']
+
+    def test_returns_empty_list_when_master_zone_has_no_endpoints(self):
+        """Master zone present but endpoints list is empty."""
+        period = _make_period(
+            master_zone_id='zone-a',
+            master_zonegroup_id='zg-1',
+            zonegroups=[
+                _make_zonegroup(
+                    'zg-1', 'default', 'zone-a',
+                    zones=[_make_zone_entry('zone-a', 'zone-a', endpoints=[])],
+                    is_master=True,
+                )
+            ],
+        )
+        rgwam = _make_rgwam()
+        assert rgwam._get_master_zone_ep_from_period(period) == []
+
+    def test_skips_non_master_zonegroups(self):
+        """Only the master zonegroup is searched; secondary zonegroups are ignored."""
+        period = _make_period(
+            master_zone_id='zone-a',
+            master_zonegroup_id='zg-1',
+            zonegroups=[
+                _make_zonegroup(
+                    'zg-secondary', 'secondary', 'zone-b',
+                    zones=[
+                        _make_zone_entry('zone-b', 'zone-b',
+                                         endpoints=['http://secondary:8080']),
+                    ],
+                    is_master=False,
+                ),
+                _make_zonegroup(
+                    'zg-1', 'default', 'zone-a',
+                    zones=[
+                        _make_zone_entry('zone-a', 'zone-a',
+                                         endpoints=['http://primary:8080']),
+                    ],
+                    is_master=True,
+                ),
+            ],
+        )
+        rgwam = _make_rgwam()
+        assert rgwam._get_master_zone_ep_from_period(period) == ['http://primary:8080']
+
+    def test_returns_empty_list_when_no_zonegroups(self):
+        """Period map with an empty zonegroup list returns []."""
+        period = _make_period('zone-a', 'zg-1', zonegroups=[])
+        rgwam = _make_rgwam()
+        assert rgwam._get_master_zone_ep_from_period(period) == []
+
+    def test_returns_empty_list_when_period_map_absent(self):
+        """Graceful degradation when 'period_map' key is missing entirely."""
+        period = {
+            'id': 'p1', 'epoch': 1, 'master_zone': 'z1',
+            'master_zonegroup': 'zg1', 'realm_id': 'r1',
+        }
+        rgwam = _make_rgwam()
+        assert rgwam._get_master_zone_ep_from_period(period) == []
+
+
+# ---------------------------------------------------------------------------
+# Tests for get_realms_info — primary-site scenario
+# ---------------------------------------------------------------------------
+
+class TestGetRealmsInfoPrimary:
+    """On a primary site the master zone params exist locally."""
+
+    def _setup_primary(self, rgwam, realm_name, realm_id,
+                       master_zone_id, endpoint, access_key, secret):
+        """Wire up all mocks for a single-realm primary-site setup."""
+        period = _make_period(
+            master_zone_id=master_zone_id,
+            master_zonegroup_id='zg-1',
+            realm_id=realm_id,
+            zonegroups=[
+                _make_zonegroup(
+                    'zg-1', 'default', master_zone_id,
+                    zones=[
+                        _make_zone_entry(master_zone_id, 'zone-primary',
+                                         endpoints=[endpoint]),
+                    ],
+                    is_master=True,
+                )
+            ],
+        )
+        realm_key = EntityKey(realm_name, realm_id)
+
+        rgwam.realm_op = MagicMock(return_value=MagicMock(
+            list=MagicMock(return_value=[realm_name]),
+            get=MagicMock(return_value={'name': realm_name, 'id': realm_id}),
+        ))
+        rgwam.period_op = MagicMock(return_value=MagicMock(
+            get=MagicMock(return_value=period),
+        ))
+        rgwam.zone_op = MagicMock(return_value=MagicMock(
+            get=MagicMock(return_value=_zone_params(
+                master_zone_id, 'zone-primary', access_key, secret
+            )),
+            list=MagicMock(return_value=['zone-primary']),
+        ))
+        return realm_key
+
+    def test_primary_site_returns_correct_info(self):
+        rgwam = _make_rgwam()
+        self._setup_primary(
+            rgwam,
+            realm_name='my-realm',
+            realm_id='realm-id-1',
+            master_zone_id='zone-id-1',
+            endpoint='http://primary-host:8080',
+            access_key='ACCESS_KEY',
+            secret='SECRET',
+        )
+
+        result = rgwam.get_realms_info()
+
+        assert len(result) == 1
+        info = result[0]
+        assert info['realm_name'] == 'my-realm'
+        assert info['realm_id'] == 'realm-id-1'
+        assert info['master_zone_id'] == 'zone-id-1'
+        assert info['endpoint'] == 'http://primary-host:8080'
+        assert info['access_key'] == 'ACCESS_KEY'
+        assert info['secret'] == 'SECRET'
+
+
+# ---------------------------------------------------------------------------
+# Tests for get_realms_info — secondary-site (fallback) scenario
+# ---------------------------------------------------------------------------
+
+class TestGetRealmsInfoSecondary:
+    """On a secondary site the master zone params are absent; a local zone
+    belonging to the same realm is used as fallback."""
+
+    def _cmd_error(self):
+        return RGWAMCmdRunException('zone get', -2, '', 'ENOENT')
+
+    def _setup_secondary(self, rgwam, realm_name, realm_id,
+                         master_zone_id, secondary_zone_id,
+                         secondary_zone_name, endpoint,
+                         access_key='AK', secret='SK',
+                         extra_zone_name=None, extra_realm_zone_id=None):
+        """
+        Wire up mocks for a secondary-site scenario.
+
+        If extra_zone_name / extra_realm_zone_id are provided a second zone
+        (belonging to a *different* realm) is also present in zone list().
+        """
+        period = _make_period(
+            master_zone_id=master_zone_id,
+            master_zonegroup_id='zg-1',
+            realm_id=realm_id,
+            zonegroups=[
+                _make_zonegroup(
+                    'zg-1', 'default', master_zone_id,
+                    zones=[
+                        _make_zone_entry(master_zone_id, 'zone-primary',
+                                         endpoints=[endpoint]),
+                        _make_zone_entry(secondary_zone_id, secondary_zone_name,
+                                         endpoints=[]),
+                    ],
+                    is_master=True,
+                )
+            ],
+        )
+
+        # 'zone get --zone-id=<master_zone_id>' raises ENOENT (not local)
+        # 'zone get --rgw-zone=<secondary_zone_name>' succeeds
+        local_zone_params = _zone_params(secondary_zone_id, secondary_zone_name,
+                                         access_key, secret)
+
+        zone_list = [secondary_zone_name]
+        if extra_zone_name:
+            zone_list.append(extra_zone_name)
+
+        def zone_get_side_effect(entity):
+            # First call is by EntityID (master zone — raises)
+            if isinstance(entity, EntityID):
+                raise self._cmd_error()
+            # Subsequent calls are by EntityName
+            if entity.name == secondary_zone_name:
+                return local_zone_params
+            if extra_zone_name and entity.name == extra_zone_name:
+                # Belongs to a different realm
+                return _zone_params(extra_realm_zone_id or 'other-zone-id',
+                                    extra_zone_name, 'OTHER_AK', 'OTHER_SK')
+            raise self._cmd_error()
+
+        rgwam.realm_op = MagicMock(return_value=MagicMock(
+            list=MagicMock(return_value=[realm_name]),
+            get=MagicMock(return_value={'name': realm_name, 'id': realm_id}),
+        ))
+        rgwam.period_op = MagicMock(return_value=MagicMock(
+            get=MagicMock(return_value=period),
+        ))
+        rgwam.zone_op = MagicMock(return_value=MagicMock(
+            get=MagicMock(side_effect=zone_get_side_effect),
+            list=MagicMock(return_value=zone_list),
+        ))
+
+    def test_secondary_site_uses_local_zone_credentials(self):
+        """When master zone params are absent the local secondary zone's
+        system_key is returned."""
+        rgwam = _make_rgwam()
+        self._setup_secondary(
+            rgwam,
+            realm_name='my-realm',
+            realm_id='realm-id-1',
+            master_zone_id='zone-primary-id',
+            secondary_zone_id='zone-secondary-id',
+            secondary_zone_name='zone-secondary',
+            endpoint='http://primary-host:8080',
+            access_key='SECONDARY_AK',
+            secret='SECONDARY_SK',
+        )
+
+        result = rgwam.get_realms_info()
+
+        assert len(result) == 1
+        info = result[0]
+        assert info['realm_name'] == 'my-realm'
+        assert info['endpoint'] == 'http://primary-host:8080'
+        assert info['access_key'] == 'SECONDARY_AK'
+        assert info['secret'] == 'SECONDARY_SK'
+
+    def test_secondary_site_ignores_zone_from_other_realm(self):
+        """When zone list() contains a zone from a different realm (not in the
+        current period's zone IDs), it must be skipped; only the zone that
+        belongs to this realm is used."""
+        rgwam = _make_rgwam()
+        # extra_zone_name belongs to a different realm (its ID is not in the period)
+        self._setup_secondary(
+            rgwam,
+            realm_name='my-realm',
+            realm_id='realm-id-1',
+            master_zone_id='zone-primary-id',
+            secondary_zone_id='zone-secondary-id',
+            secondary_zone_name='zone-secondary',
+            endpoint='http://primary-host:8080',
+            access_key='CORRECT_AK',
+            secret='CORRECT_SK',
+            extra_zone_name='zone-other-realm',
+            extra_realm_zone_id='zone-other-id',   # NOT in this realm's period
+        )
+
+        # Make zone-other-realm appear *first* in the list to ensure the filter
+        # rejects it and continues to zone-secondary.
+        rgwam.zone_op.return_value.list.return_value = [
+            'zone-other-realm', 'zone-secondary'
+        ]
+
+        result = rgwam.get_realms_info()
+
+        assert len(result) == 1
+        info = result[0]
+        assert info['access_key'] == 'CORRECT_AK'
+        assert info['secret'] == 'CORRECT_SK'
+
+    def test_secondary_site_no_local_zone_yields_empty_creds(self):
+        """If no local zone for this realm is found, access_key and secret
+        fall back to empty strings."""
+        rgwam = _make_rgwam()
+        period = _make_period(
+            master_zone_id='zone-primary-id',
+            master_zonegroup_id='zg-1',
+            realm_id='realm-id-1',
+            zonegroups=[
+                _make_zonegroup(
+                    'zg-1', 'default', 'zone-primary-id',
+                    zones=[
+                        _make_zone_entry('zone-primary-id', 'zone-primary',
+                                         endpoints=['http://primary:8080']),
+                    ],
+                    is_master=True,
+                )
+            ],
+        )
+
+        def zone_get_always_fails(entity):
+            raise RGWAMCmdRunException('zone get', -2, '', 'ENOENT')
+
+        rgwam.realm_op = MagicMock(return_value=MagicMock(
+            list=MagicMock(return_value=['my-realm']),
+            get=MagicMock(return_value={'name': 'my-realm', 'id': 'realm-id-1'}),
+        ))
+        rgwam.period_op = MagicMock(return_value=MagicMock(
+            get=MagicMock(return_value=period),
+        ))
+        rgwam.zone_op = MagicMock(return_value=MagicMock(
+            get=MagicMock(side_effect=zone_get_always_fails),
+            list=MagicMock(return_value=['zone-primary']),
+        ))
+
+        result = rgwam.get_realms_info()
+
+        assert len(result) == 1
+        assert result[0]['access_key'] == ''
+        assert result[0]['secret'] == ''