summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorHezko <tomer.haska@gmail.com>2025-01-27 18:53:58 +0100
committerGitHub <noreply@github.com>2025-01-27 18:53:58 +0100
commitfe3413f2169e27be52793e2303a9caa24583a308 (patch)
tree32c1d1deccda2ff844dbf09eb99e8160e7e62ea4 /src
parentMerge PR #60996 into main (diff)
parentmgr/dashboard: Introduce nvmeof cli (diff)
downloadceph-main.tar.xz
ceph-main.zip
Merge pull request #61392 from Hezko/nvmeof-gw-info-cliHEADmain
Dashboard: Introduce nvmeof cli commands
Diffstat (limited to 'src')
-rw-r--r--src/pybind/mgr/dashboard/controllers/nvmeof.py21
-rw-r--r--src/pybind/mgr/dashboard/module.py1
-rw-r--r--src/pybind/mgr/dashboard/services/nvmeof_cli.py42
-rw-r--r--src/pybind/mgr/dashboard/services/nvmeof_client.py4
-rw-r--r--src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py87
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()