From: Aashish Sharma Date: Wed, 15 Jul 2026 09:24:27 +0000 (+0530) Subject: python-common/rgw: fix get_realm_tokens returning empty result for realms with a... X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=b6b591dcce7418e907b80d58ff9a6154d10ae76c;p=ceph.git python-common/rgw: fix get_realm_tokens returning empty result for realms with a secondary zone 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 --- diff --git a/src/python-common/ceph/rgw/rgwam_core.py b/src/python-common/ceph/rgw/rgwam_core.py index 20537e49b7e..2e0a3bdee31 100644 --- a/src/python-common/ceph/rgw/rgwam_core.py +++ b/src/python-common/ceph/rgw/rgwam_core.py @@ -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=' 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 index 00000000000..f6658c27a0b --- /dev/null +++ b/src/python-common/ceph/tests/test_rgwam_core.py @@ -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=' raises ENOENT (not local) + # 'zone get --rgw-zone=' 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'] == ''