]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Introduce nvmeof cli 61392/head
authorTomer Haskalovitch <il033030@Tomers-MBP.lan>
Wed, 15 Jan 2025 09:49:18 +0000 (11:49 +0200)
committerTomer Haskalovitch <il033030@tomers-mbp.givatayim.il.ibm.com>
Mon, 27 Jan 2025 10:27:18 +0000 (12:27 +0200)
Extends ceph cli with nvmeof commands

fixes: https://tracker.ceph.com/issues/62705

Signed-off-by: Tomer Haskalovitch <il033030@Tomers-MBP.lan>
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/module.py
src/pybind/mgr/dashboard/services/nvmeof_cli.py
src/pybind/mgr/dashboard/services/nvmeof_client.py
src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py [new file with mode: 0644]

index 519c310a98bcca939c3f132e50c8134e4650126d..762a2bb1c52db1cae089a21d10961847a1080709 100644 (file)
@@ -7,6 +7,7 @@ from orchestrator import OrchestratorError
 from .. import mgr
 from ..model import nvmeof as model
 from ..security import Scope
+from ..services.nvmeof_cli import NvmeofCLICommand
 from ..services.orchestrator import OrchClient
 from ..tools import str_to_bool
 from . import APIDoc, APIRouter, BaseController, CreatePermission, \
@@ -30,6 +31,7 @@ else:
     @APIDoc("NVMe-oF Gateway Management API", "NVMe-oF Gateway")
     class NVMeoFGateway(RESTController):
         @EndpointDoc("Get information about the NVMeoF gateway")
+        @NvmeofCLICommand("nvmeof gw info")
         @map_model(model.GatewayInfo)
         @handle_nvmeof_error
         def list(self, gw_group: Optional[str] = None):
@@ -54,6 +56,7 @@ else:
     @APIDoc("NVMe-oF Subsystem Management API", "NVMe-oF Subsystem")
     class NVMeoFSubsystem(RESTController):
         @EndpointDoc("List all NVMeoF subsystems")
+        @NvmeofCLICommand("nvmeof subsystem list")
         @map_collection(model.Subsystem, pick="subsystems")
         @handle_nvmeof_error
         def list(self, gw_group: Optional[str] = None):
@@ -68,6 +71,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof subsystem get")
         @map_model(model.Subsystem, first="subsystems")
         @handle_nvmeof_error
         def get(self, nqn: str, gw_group: Optional[str] = None):
@@ -84,6 +88,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof subsystem add")
         @empty_response
         @handle_nvmeof_error
         def create(self, nqn: str, enable_ha: bool, max_namespaces: int = 1024,
@@ -98,10 +103,11 @@ else:
             "Delete an existing NVMeoF subsystem",
             parameters={
                 "nqn": Param(str, "NVMeoF subsystem NQN"),
-                "force": Param(bool, "Force delete", "false"),
+                "force": Param(bool, "Force delete", True, False),
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof subsystem del")
         @empty_response
         @handle_nvmeof_error
         def delete(self, nqn: str, force: Optional[str] = "false", gw_group: Optional[str] = None):
@@ -121,6 +127,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof listener list")
         @map_collection(model.Listener, pick="listeners")
         @handle_nvmeof_error
         def list(self, nqn: str, gw_group: Optional[str] = None):
@@ -139,6 +146,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof listener add")
         @empty_response
         @handle_nvmeof_error
         def create(
@@ -171,6 +179,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof listener del")
         @empty_response
         @handle_nvmeof_error
         def delete(
@@ -204,6 +213,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof ns list")
         @map_collection(model.Namespace, pick="namespaces")
         @handle_nvmeof_error
         def list(self, nqn: str, gw_group: Optional[str] = None):
@@ -219,6 +229,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof ns get")
         @map_model(model.Namespace, first="namespaces")
         @handle_nvmeof_error
         def get(self, nqn: str, nsid: str, gw_group: Optional[str] = None):
@@ -236,6 +247,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof ns get_io_stats")
         @map_model(model.NamespaceIOStats)
         @handle_nvmeof_error
         def io_stats(self, nqn: str, nsid: str, gw_group: Optional[str] = None):
@@ -257,6 +269,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof ns add")
         @map_model(model.NamespaceCreation)
         @handle_nvmeof_error
         def create(
@@ -296,6 +309,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof ns update")
         @empty_response
         @handle_nvmeof_error
         def update(
@@ -360,6 +374,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof ns del")
         @empty_response
         @handle_nvmeof_error
         def delete(self, nqn: str, nsid: str, gw_group: Optional[str] = None):
@@ -378,6 +393,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof host list")
         @map_collection(
             model.Host,
             pick="hosts",
@@ -400,6 +416,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof host add")
         @empty_response
         @handle_nvmeof_error
         def create(self, nqn: str, host_nqn: str, gw_group: Optional[str] = None):
@@ -415,6 +432,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof host del")
         @empty_response
         @handle_nvmeof_error
         def delete(self, nqn: str, host_nqn: str, gw_group: Optional[str] = None):
@@ -432,6 +450,7 @@ else:
                 "gw_group": Param(str, "NVMeoF gateway group", True, None),
             },
         )
+        @NvmeofCLICommand("nvmeof connection list")
         @map_collection(model.Connection, pick="connections")
         @handle_nvmeof_error
         def list(self, nqn: str, gw_group: Optional[str] = None):
index ac6e094a4aad806876e00882a1dcdd9878733fcf..846401f76fc7113448ebcddddcdae814aac39142 100644 (file)
@@ -29,6 +29,7 @@ from mgr_util import ServerConfigException, build_url, \
     create_self_signed_cert, get_default_addr, verify_tls_files
 
 from . import mgr
+from .controllers import nvmeof  # noqa # pylint: disable=unused-import
 from .controllers import Router, json_error_page
 from .grafana import push_local_dashboards
 from .services import nvmeof_cli  # noqa # pylint: disable=unused-import
index bd9de3504482e1dfc9f13d57087bca84c74352a2..b181fb0f5fe1d660946905bf744ff6c2a0d762c9 100644 (file)
@@ -1,9 +1,13 @@
 # -*- coding: utf-8 -*-
 import errno
 import json
+from typing import Any, Dict, Optional
 
-from mgr_module import CLICheckNonemptyFileInput, CLIReadCommand, CLIWriteCommand
+import yaml
+from mgr_module import CLICheckNonemptyFileInput, CLICommand, CLIReadCommand, \
+    CLIWriteCommand, HandleCommandResult, HandlerFuncType
 
+from ..exceptions import DashboardException
 from ..rest_client import RequestException
 from .nvmeof_conf import ManagedByOrchestratorException, \
     NvmeofGatewayAlreadyExists, NvmeofGatewaysConfig
@@ -45,3 +49,39 @@ def remove_nvmeof_gateway(_, name: str, daemon_name: str = ''):
         return 0, 'Success', ''
     except ManagedByOrchestratorException as ex:
         return -errno.EINVAL, '', str(ex)
+
+
+class NvmeofCLICommand(CLICommand):
+    def __call__(self, func) -> HandlerFuncType:  # type: ignore
+        # pylint: disable=useless-super-delegation
+        """
+        This method is being overriden solely to be able to disable the linters checks for typing.
+        The NvmeofCLICommand decorator assumes a different type returned from the
+        function it wraps compared to CLICmmand, breaking a Liskov substitution principal,
+        hence triggering linters alerts.
+        """
+        return super().__call__(func)
+
+    def call(self,
+             mgr: Any,
+             cmd_dict: Dict[str, Any],
+             inbuf: Optional[str] = None) -> HandleCommandResult:
+        try:
+            ret = super().call(mgr, cmd_dict, inbuf)
+            out_format = cmd_dict.get('format')
+            if out_format == 'json' or not out_format:
+                if ret is None:
+                    out = ''
+                else:
+                    out = json.dumps(ret)
+            elif out_format == 'yaml':
+                if ret is None:
+                    out = ''
+                else:
+                    out = yaml.dump(ret)
+            else:
+                return HandleCommandResult(-errno.EINVAL, '',
+                                           f"format '{out_format}' is not implemented")
+            return HandleCommandResult(0, out, '')
+        except DashboardException as e:
+            return HandleCommandResult(-errno.EINVAL, '', str(e))
index e0ea6d1e48b35191e999e16f51fcc51d331ce018..0490b2728f37a6989e2c234ecc558a796e2a0281 100644 (file)
@@ -13,8 +13,8 @@ try:
     import grpc._channel  # type: ignore
     from google.protobuf.message import Message  # type: ignore
 
-    from .proto import gateway_pb2 as pb2
-    from .proto import gateway_pb2_grpc as pb2_grpc
+    from .proto import gateway_pb2 as pb2  # type: ignore
+    from .proto import gateway_pb2_grpc as pb2_grpc  # type: ignore
 except ImportError:
     grpc = None
 else:
diff --git a/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py b/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py
new file mode 100644 (file)
index 0000000..b17940b
--- /dev/null
@@ -0,0 +1,87 @@
+import errno
+from unittest.mock import MagicMock
+
+import pytest
+from mgr_module import CLICommand, HandleCommandResult
+
+from ..services.nvmeof_cli import NvmeofCLICommand
+
+
+@pytest.fixture(scope="class", name="sample_command")
+def fixture_sample_command():
+    test_cmd = "test command"
+
+    @NvmeofCLICommand(test_cmd)
+    def func(_): # noqa # pylint: disable=unused-variable
+        return {'a': '1', 'b': 2}
+    yield test_cmd
+    del NvmeofCLICommand.COMMANDS[test_cmd]
+    assert test_cmd not in NvmeofCLICommand.COMMANDS
+
+
+@pytest.fixture(name='base_call_mock')
+def fixture_base_call_mock(monkeypatch):
+    mock_result = {'a': 'b'}
+    super_mock = MagicMock()
+    super_mock.return_value = mock_result
+    monkeypatch.setattr(CLICommand, 'call', super_mock)
+    return super_mock
+
+
+@pytest.fixture(name='base_call_return_none_mock')
+def fixture_base_call_return_none_mock(monkeypatch):
+    mock_result = None
+    super_mock = MagicMock()
+    super_mock.return_value = mock_result
+    monkeypatch.setattr(CLICommand, 'call', super_mock)
+    return super_mock
+
+
+class TestNvmeofCLICommand:
+    def test_command_being_added(self, sample_command):
+        assert sample_command in NvmeofCLICommand.COMMANDS
+        assert isinstance(NvmeofCLICommand.COMMANDS[sample_command], NvmeofCLICommand)
+
+    def test_command_return_cmd_result_default_format(self, base_call_mock, sample_command):
+        result = NvmeofCLICommand.COMMANDS[sample_command].call(MagicMock(), {})
+        assert isinstance(result, HandleCommandResult)
+        assert result.retval == 0
+        assert result.stdout == '{"a": "b"}'
+        assert result.stderr == ''
+        base_call_mock.assert_called_once()
+
+    def test_command_return_cmd_result_json_format(self, base_call_mock, sample_command):
+        result = NvmeofCLICommand.COMMANDS[sample_command].call(MagicMock(), {'format': 'json'})
+        assert isinstance(result, HandleCommandResult)
+        assert result.retval == 0
+        assert result.stdout == '{"a": "b"}'
+        assert result.stderr == ''
+        base_call_mock.assert_called_once()
+
+    def test_command_return_cmd_result_yaml_format(self, base_call_mock, sample_command):
+        result = NvmeofCLICommand.COMMANDS[sample_command].call(MagicMock(), {'format': 'yaml'})
+        assert isinstance(result, HandleCommandResult)
+        assert result.retval == 0
+        assert result.stdout == 'a: b\n'
+        assert result.stderr == ''
+        base_call_mock.assert_called_once()
+
+    def test_command_return_cmd_result_invalid_format(self, base_call_mock, sample_command):
+        mock_result = {'a': 'b'}
+        super_mock = MagicMock()
+        super_mock.call.return_value = mock_result
+
+        result = NvmeofCLICommand.COMMANDS[sample_command].call(MagicMock(), {'format': 'invalid'})
+        assert isinstance(result, HandleCommandResult)
+        assert result.retval == -errno.EINVAL
+        assert result.stdout == ''
+        assert result.stderr
+        base_call_mock.assert_called_once()
+
+    def test_command_return_empty_cmd_result(self, base_call_return_none_mock, sample_command):
+        result = NvmeofCLICommand.COMMANDS[sample_command].call(MagicMock(), {})
+        assert isinstance(result, HandleCommandResult)
+        assert result.retval == 0
+        assert result.stdout == ''
+        assert result.stderr == ''
+        base_call_return_none_mock.assert_called_once()