]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
dashboard: add location field to NVMeoF namespace and gateway group APIs 67400/head
authorVictoria Mackie <victoriam@uk.ibm.com>
Fri, 13 Feb 2026 21:40:01 +0000 (21:40 +0000)
committerVictoria Mackie <victoriam@uk.ibm.com>
Tue, 3 Mar 2026 10:13:02 +0000 (10:13 +0000)
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 <victoriam@uk.ibm.com>
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/model/nvmeof.py
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/nvmeof_client.py

index 7237f1e9c13f077f0a12d7590a3611438c0ce8b6..912df4ef2477e68ab22f534ecb0bd13d75d1be58 100644 (file)
@@ -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
 
index c4ea09d8ee4265c96b8d668d234b3fc3fc3aa6d3..e085fab5a09d346a3914765e98405b3a5fdf08df 100644 (file)
@@ -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):
index 70fc3fffa627eff49d7c535eddad5d99b32af70d..1edcafed2ee21cd83ba2d301c084ad52459bf94b 100755 (executable)
@@ -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
index 18b48831a76480596c3ef68fb1317ace0a238c15..b208bb9dca05c90d71ec6b642fa6e74b93342837 100644 (file)
@@ -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 []