From: Victoria Mackie Date: Fri, 13 Feb 2026 21:40:01 +0000 (+0000) Subject: dashboard: add location field to NVMeoF namespace and gateway group APIs X-Git-Tag: testing/wip-vshankar-testing-20260304.135307~17^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=5821d1b03e8317242ceeb66ab2f32937bf986db0;p=ceph-ci.git dashboard: add location field to NVMeoF namespace and gateway group APIs Namespace location: - Add location field to Namespace model in nvmeof.py - Add location parameter to PATCH /api/nvmeof/subsystem/{nqn}/namespace/{nsid} - Location can now be retrieved via GET and set via PATCH Gateway group locations: - Add locations array to gateway group endpoint response - Extract locations from all gateways in a service group - Add _get_gateway_locations() helper method using nvme-gw show command - Locations appear in placement.locations for each service Signed-off-by: Victoria Mackie --- diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index 7237f1e9c13..912df4ef247 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -13,6 +13,7 @@ from ..model import nvmeof as model from ..security import Scope from ..services.nvmeof_cli import NvmeofCLICommand, convert_to_bytes, \ escape_address_if_ipv6, format_host_updates +from ..services.nvmeof_client import get_gateway_locations from ..services.orchestrator import OrchClient from ..tools import str_to_bool from . import APIDoc, APIRouter, BaseController, CreatePermission, \ @@ -52,15 +53,56 @@ else: @ReadPermission @Endpoint('GET') def group(self): + # pylint: disable=too-many-nested-blocks try: orch = OrchClient.instance() - return orch.services.list(service_type='nvmeof') + services_result = orch.services.list(service_type='nvmeof') + + if isinstance(services_result, tuple) and len(services_result) == 2: + services, count = services_result + else: + services = services_result + count = len(services) if services else 0 + + if services: + result = [] + for service in services: + service_dict = service.to_json() if hasattr(service, 'to_json') else service + + if isinstance(service_dict, dict): + # Extract pool and group from spec + spec = service_dict.get('spec', {}) + if isinstance(spec, dict): + pool = spec.get('pool') + group = spec.get('group') + + if pool and group: + # Get hosts list from placement to match location order + placement = service_dict.get('placement', {}) + hosts = placement.get('hosts', []) + + # Get locations in the same order as hosts + locations = get_gateway_locations(pool, group, hosts) + + if 'placement' not in service_dict: + service_dict['placement'] = {} + + service_dict['placement']['locations'] = locations + + result.append(service_dict) + + return (result, count) + + return services_result except OrchestratorError as e: # just return none instead of raising an exception # since we need this to work regardless of the status # of orchestrator in UI logger.error('Failed to fetch the gateway groups: %s', e) return None + except Exception as e: # pylint: disable=broad-except + logger.error("Unexpected error in group(): %s", e, exc_info=True) + return None @ReadPermission @Endpoint('GET', '/version') @@ -607,6 +649,7 @@ else: ), "disable_auto_resize": Param(str, "Disable auto resize", True, None), "read_only": Param(str, "Read only namespace", True, None), + "location": Param(str, "Gateway location for namespace", True, None), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), }, @@ -629,6 +672,7 @@ else: no_auto_visible: Optional[bool] = False, disable_auto_resize: Optional[bool] = False, read_only: Optional[bool] = False, + location: Optional[str] = None, gw_group: Optional[str] = None, server_address: Optional[str] = None, rados_namespace: Optional[str] = None, @@ -651,7 +695,8 @@ else: force=force, no_auto_visible=no_auto_visible, disable_auto_resize=disable_auto_resize, - read_only=read_only + read_only=read_only, + location=location ) ) @@ -676,6 +721,7 @@ else: "load_balancing_group": Param(int, "Load balancing group"), "disable_auto_resize": Param(str, "Disable auto resize", True, None), "read_only": Param(str, "Read only namespace", True, None), + "location": Param(str, "Gateway location for namespace", True, None), "force": Param( bool, "Force create namespace even it image is used by other namespace" @@ -706,6 +752,7 @@ else: no_auto_visible: Optional[bool] = False, disable_auto_resize: Optional[bool] = False, read_only: Optional[bool] = False, + location: Optional[str] = None, gw_group: Optional[str] = None, server_address: Optional[str] = None, rados_namespace: Optional[str] = None, @@ -742,7 +789,8 @@ else: force=force, no_auto_visible=no_auto_visible, disable_auto_resize=disable_auto_resize, - read_only=read_only + read_only=read_only, + location=location ) ) @@ -1198,6 +1246,7 @@ else: "r_mbytes_per_second": Param(int, "Read MB/s"), "w_mbytes_per_second": Param(int, "Write MB/s"), "trash_image": Param(bool, "Trash RBD image after removing namespace"), + "location": Param(str, "Namespace location"), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), }, @@ -1215,6 +1264,7 @@ else: r_mbytes_per_second: Optional[int] = None, w_mbytes_per_second: Optional[int] = None, trash_image: Optional[bool] = None, + location: Optional[str] = None, gw_group: Optional[str] = None, server_address: Optional[str] = None, ): @@ -1270,6 +1320,20 @@ else: if resp.status != 0: contains_failure = True + if location is not None: + resp = NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.namespace_change_location( + NVMeoFClient.pb2.namespace_change_location_req( + subsystem_nqn=nqn, + nsid=int(nsid), + location=location + ) + ) + if resp.status != 0: + contains_failure = True + if contains_failure: cherrypy.response.status = 202 diff --git a/src/pybind/mgr/dashboard/model/nvmeof.py b/src/pybind/mgr/dashboard/model/nvmeof.py index c4ea09d8ee4..e085fab5a09 100644 --- a/src/pybind/mgr/dashboard/model/nvmeof.py +++ b/src/pybind/mgr/dashboard/model/nvmeof.py @@ -145,6 +145,7 @@ class Namespace(NamedTuple): trash_image: Optional[bool] disable_auto_resize: Optional[bool] read_only: Optional[bool] + location: Optional[str] class NamespaceList(NamedTuple): diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 70fc3fffa62..1edcafed2ee 100755 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -13822,6 +13822,9 @@ paths: load_balancing_group: description: Load balancing group type: integer + location: + description: Gateway location for namespace + type: string no_auto_visible: default: false description: Namespace will be visible only for the allowed hosts @@ -14033,6 +14036,9 @@ paths: load_balancing_group: description: Load balancing group type: integer + location: + description: Namespace location + type: string r_mbytes_per_second: description: Read MB/s type: integer diff --git a/src/pybind/mgr/dashboard/services/nvmeof_client.py b/src/pybind/mgr/dashboard/services/nvmeof_client.py index 18b48831a76..b208bb9dca0 100644 --- a/src/pybind/mgr/dashboard/services/nvmeof_client.py +++ b/src/pybind/mgr/dashboard/services/nvmeof_client.py @@ -6,6 +6,7 @@ from typing import Annotated, Any, Callable, Dict, Generator, List, \ NamedTuple, Optional, Type, get_args, get_origin from ..exceptions import DashboardException +from ..services.ceph_service import CephService from .nvmeof_conf import NvmeofGatewaysConfig, is_mtls_enabled logger = logging.getLogger("nvmeof_client") @@ -262,3 +263,67 @@ else: return field_to_ret return wrapper return decorator + + +def get_gateway_locations(pool: str, group: str, hosts: Optional[List[str]] = None): + """ + Get locations for gateways in a service group using nvme-gw show command. + + Args: + pool: The RBD pool name + group: The NVMeoF gateway group name + hosts: Optional list of hostnames to match locations to (in order) + + Returns: + If hosts provided: List of location strings matching the order of hosts + If hosts not provided: List of unique location strings (sorted) + """ + try: # pylint: disable=too-many-nested-blocks + if not pool or not group: + logger.warning('Pool or group not provided for location lookup: pool=%s, group=%s', + pool, group) + return [] + + result = CephService.send_command('mon', 'nvme-gw show', + pool=pool, group=group) + + # Build a mapping of hostname to location + host_to_location = {} + if isinstance(result, dict): + gateways = result.get('Created Gateways:', []) + + for gw in gateways: + if isinstance(gw, dict): + gw_id = gw.get('gw-id', '') + location = gw.get('location', '') + + # Extract hostname from gw-id (format: client.nvmeof.pool.group.hostname.xxx) + if gw_id: + parts = gw_id.split('.') + if len(parts) >= 5: + hostname = parts[4] + if location: + host_to_location[hostname] = location + else: + # If format is unexpected, log warning + logger.warning('Unexpected gateway ID format: %s', gw_id) + + # If hosts list provided, return locations in the same order + if hosts: + locations = [] + for host in hosts: + # Get location for this host, empty string if not found + location = host_to_location.get(host, '') + if not location: + logger.debug('No location found for host: %s', host) + locations.append(location) + return locations + + # Otherwise return unique sorted locations + unique_locations = set(host_to_location.values()) + return sorted(list(unique_locations)) + + except Exception as e: # pylint: disable=broad-except + logger.error('Failed to get gateway locations for pool=%s, group=%s: %s', + pool, group, e) + return []