diff options
author | Hezko <tomer.haska@gmail.com> | 2025-01-27 18:53:58 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-01-27 18:53:58 +0100 |
commit | fe3413f2169e27be52793e2303a9caa24583a308 (patch) | |
tree | 32c1d1deccda2ff844dbf09eb99e8160e7e62ea4 /src | |
parent | Merge PR #60996 into main (diff) | |
parent | mgr/dashboard: Introduce nvmeof cli (diff) | |
download | ceph-main.tar.xz ceph-main.zip |
Dashboard: Introduce nvmeof cli commands
Diffstat (limited to 'src')
-rw-r--r-- | src/pybind/mgr/dashboard/controllers/nvmeof.py | 21 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/module.py | 1 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/services/nvmeof_cli.py | 42 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/services/nvmeof_client.py | 4 | ||||
-rw-r--r-- | src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py | 87 |
5 files changed, 151 insertions, 4 deletions
diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index 519c310a98b..762a2bb1c52 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -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): diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index ac6e094a4aa..846401f76fc 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -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 diff --git a/src/pybind/mgr/dashboard/services/nvmeof_cli.py b/src/pybind/mgr/dashboard/services/nvmeof_cli.py index bd9de350448..b181fb0f5fe 100644 --- a/src/pybind/mgr/dashboard/services/nvmeof_cli.py +++ b/src/pybind/mgr/dashboard/services/nvmeof_cli.py @@ -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)) diff --git a/src/pybind/mgr/dashboard/services/nvmeof_client.py b/src/pybind/mgr/dashboard/services/nvmeof_client.py index e0ea6d1e48b..0490b2728f3 100644 --- a/src/pybind/mgr/dashboard/services/nvmeof_client.py +++ b/src/pybind/mgr/dashboard/services/nvmeof_client.py @@ -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 index 00000000000..b17940bb0cb --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py @@ -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() |