[PATCH blktests 06/10] contrib: make nvme-target-control a standalone project
Daniel Wagner
dwagner at suse.de
Wed Nov 4 08:44:45 PST 2026
contrib/nvme_target_control.py can't set up a target with the upstream
tools. It needs an nvmetcli REST API which was never merged, and its
nvmetcli client crashes with configshell-fb 2.0. Its configuration also
repeats the target setup on the host.
Replace it with contrib/nvme-target-control. It keeps the command line
blktests calls and adds commands for ports and host keys. The
configuration only says how to reach the target. The built-in nvmet
target configures configfs over ssh, other targets and channels are
plugins. README.md documents the configuration and the plugin
interface.
Signed-off-by: Daniel Wagner <dwagner at suse.de>
---
Documentation/running-tests.md | 4 +
contrib/nvme-target-control/.gitignore | 1 +
contrib/nvme-target-control/README.md | 129 +++++++
contrib/nvme-target-control/nvme-target-control | 19 ++
.../nvme_target_control/__init__.py | 3 +
.../nvme_target_control/channel.py | 113 ++++++
.../nvme-target-control/nvme_target_control/cli.py | 205 +++++++++++
.../nvme_target_control/plugin.py | 35 ++
.../nvme_target_control/target.py | 221 ++++++++++++
.../nvme_target_control/targets/__init__.py | 3 +
.../nvme_target_control/targets/nvmet.py | 107 ++++++
.../nvme_target_control/targets/nvmet.sh | 246 +++++++++++++
contrib/nvme-target-control/pyproject.toml | 42 +++
.../tests/test_target_control.py | 380 +++++++++++++++++++++
contrib/nvme_target_control.py | 204 -----------
contrib/nvmet-subsys.jinja2 | 71 ----
16 files changed, 1508 insertions(+), 275 deletions(-)
diff --git a/Documentation/running-tests.md b/Documentation/running-tests.md
index 7d935aad3331..095d6aa4e18e 100644
--- a/Documentation/running-tests.md
+++ b/Documentation/running-tests.md
@@ -258,3 +258,7 @@ The blktests expects that the script interface implements following
cleanup:
--subsysnqn SUBSYSNQN
+
+contrib/nvme-target-control implements this interface. It configures a Linux
+nvmet target over ssh and can be extended for other targets, see
+contrib/nvme-target-control/README.md.
diff --git a/contrib/nvme-target-control/.gitignore b/contrib/nvme-target-control/.gitignore
new file mode 100644
index 000000000000..c18dd8d83cee
--- /dev/null
+++ b/contrib/nvme-target-control/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/contrib/nvme-target-control/README.md b/contrib/nvme-target-control/README.md
new file mode 100644
index 000000000000..dc421b00b788
--- /dev/null
+++ b/contrib/nvme-target-control/README.md
@@ -0,0 +1,129 @@
+# nvme-target-control
+
+Sets up and tears down remote NVMe targets for blktests. blktests calls it
+through `NVME_TARGET_CONTROL` to run the fabrics tests against a target on
+another machine instead of the local nvmet target, see
+Documentation/running-tests.md.
+
+It needs Python 3.10 or newer, and tomli on Python 3.10. It runs from the
+source tree.
+
+## Usage
+
+ NVME_TARGET_CONTROL=$PWD/contrib/nvme-target-control/nvme-target-control \
+ ./check nvme
+
+Tests which change the local nvmet configfs, e.g. `attr_qid_max`, skip with
+a remote target.
+
+## Configuration
+
+`~/.config/blktests/nvme_target_control.toml`, or the file
+`NVME_TARGET_CONTROL_CONFIG` points to. It only says how to reach the
+target, e.g. a Linux box with nvmet:
+
+ [target]
+ remote = "root at 10.0.0.1"
+
+The rest is worked out on the host or created on the target:
+
+| Value | Default |
+| --- | --- |
+| `traddr` | the address of the target, e.g. of the ssh destination |
+| `host_traddr` | the local address of the route to `traddr` |
+| `hostnqn`, `hostid` | `/etc/nvme/hostnqn` and `/etc/nvme/hostid`, else the blktests defaults |
+| `trtype` | `tcp` |
+| `trsvid` | `4420` |
+| `subsysnqn`, `subsys_uuid` | the blktests defaults |
+| `blkdev_type` | `device` |
+
+These are the values blktests reads with `config --show-*`. Each can be
+set in `[target]`, e.g. `traddr` if the data path uses another address than
+the ssh login. With `skip_setup_cleanup = true`, setup and cleanup do
+nothing, to run against a target which is configured already.
+
+## Targets
+
+`type` in `[target]` selects the target:
+
+| Type | Target |
+| --- | --- |
+| `nvmet` (default) | Linux nvmet target, configured through configfs |
+| `package.module:Class` | A `Target` subclass outside this package |
+
+### nvmet
+
+Runs the shell script `nvmet.sh` on the target through a channel. Setup
+creates the subsystem with namespace 1, allows the host and exports the
+subsystem on new ports, one unless blktests asks for more. Unless `blkdev`
+is set, the namespace is a loop device on an image file in
+`/var/tmp/nvme-target-control` on the target. Cleanup removes the
+subsystem, the ports which don't export another subsystem, the hosts no
+other subsystem allows, and the loop device and image file.
+
+A new port gets the lowest free nvmet port ID N, starting at 1, and listens
+on `traddr` at service ID `trsvid` + N - 1. The first port of a target
+without other ports listens on `trsvid`. The nvmet target implements all
+port commands: `add-ports`, `add-port`, `remove-port`, `list-ports`,
+`show-port` and `set-ana`, and `set-host`, which changes the DH-CHAP keys,
+hash and DH group of the host.
+
+| Option | Meaning |
+| --- | --- |
+| `channel` | `ssh` (default), `local` or `package.module:Class` |
+| `remote` | ssh destination; the login must not ask for a password |
+| `ssh_options` | list of extra ssh arguments |
+| `blkdev` | block device or file backing the namespace, must keep the data |
+| `img_size` | size of the image file without `blkdev`, default `1G` |
+
+The target needs sshd, configfs, losetup and the nvmet modules for the
+transport. The ssh destination may be an alias from the ssh config, `traddr`
+is then its `HostName`.
+
+## Adding a target
+
+A target is a subclass of `nvme_target_control.target.Target`. It gets the
+`[target]` section as `self.config`:
+
+```python
+from nvme_target_control.target import Subsystem, Target
+
+
+class ArrayTarget(Target):
+ def traddr(self) -> str:
+ # The address the host connects to, without contacting the
+ # target: blktests asks for it many times per test.
+ return self.config['data_address']
+
+ def setup(
+ self, subsys: Subsystem, nr_ports: int = 1, tls: str = 'none'
+ ) -> None:
+ # Create subsys.nqn with one namespace, allow subsys.hostnqn and
+ # export it on self.value('traddr'):self.value('trsvid').
+ ...
+
+ def cleanup(self, subsysnqn: str) -> None: ...
+```
+
+Select it with `type = "mymodule:ArrayTarget"`; `mymodule` must be
+importable, e.g. through `PYTHONPATH`. It can override the other defaults
+the same way, e.g. `subsysnqn` if the target names subsystems itself. A
+target with a management API, e.g. a storage array with a REST API, talks
+to it directly. A target configured with shell commands can use a channel:
+`nvme_target_control.channel.create` creates the one `[target]` names. A
+channel is a subclass of `Channel` which returns the command running
+`sh -s -- ARGS` on the target and the host name of the target.
+
+The port commands and `set-host` are optional. A target implements them
+by overriding `add_ports`, `add_port`, `remove_port`, `list_ports`,
+`show_port`, `set_ana` and `set_host`. `config --show-features` lists the
+ones it overrides, followed by the class attribute `extra_features`, e.g.
+`tls`. blktests skips the tests which need a feature the target doesn't
+list. Without `list-ports` and `show-port`, blktests connects to `traddr`
+and `trsvid`.
+
+## Development
+
+ ruff check . && ruff format --check . && mypy
+ python3 -m unittest discover -s tests
+ shellcheck -s sh nvme_target_control/targets/*.sh
diff --git a/contrib/nvme-target-control/nvme-target-control b/contrib/nvme-target-control/nvme-target-control
new file mode 100755
index 000000000000..7aedf7bfdc8c
--- /dev/null
+++ b/contrib/nvme-target-control/nvme-target-control
@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Set up and tear down remote NVMe targets for blktests.
+
+Point NVME_TARGET_CONTROL at this file, see README.md.
+"""
+
+import sys
+from pathlib import Path
+
+if sys.version_info < (3, 10):
+ sys.exit('nvme-target-control needs Python 3.10 or newer')
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from nvme_target_control.cli import main
+
+sys.exit(main())
diff --git a/contrib/nvme-target-control/nvme_target_control/__init__.py b/contrib/nvme-target-control/nvme_target_control/__init__.py
new file mode 100644
index 000000000000..e97c509fc0cf
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Set up and tear down remote NVMe targets for blktests."""
diff --git a/contrib/nvme-target-control/nvme_target_control/channel.py b/contrib/nvme-target-control/nvme_target_control/channel.py
new file mode 100644
index 000000000000..28bbb540b5b2
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/channel.py
@@ -0,0 +1,113 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Channels run shell scripts on the target.
+
+Targets which are configured with shell commands, like the Linux nvmet
+target, use a channel to reach the target machine. Targets with their own
+management API don't need one.
+"""
+
+import shlex
+import subprocess
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from typing import Any
+
+from . import plugin
+
+
+class ChannelError(Exception):
+ """A script failed on the target."""
+
+
+class Channel(ABC):
+ """Runs shell scripts on the target."""
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ self.config = config
+
+ @abstractmethod
+ def command(self, args: Sequence[str]) -> list[str]:
+ """Return the command which runs 'sh -s -- args' on the target."""
+
+ @abstractmethod
+ def host(self) -> str:
+ """Return the host name or address of the target machine."""
+
+ def run(self, script: str, args: Sequence[str]) -> str:
+ """Run script with the positional parameters args.
+
+ Return what the script printed on stdout. Its stderr is passed
+ through.
+ """
+ cmd = self.command(args)
+ ret = subprocess.run(
+ cmd,
+ input=script,
+ stdout=subprocess.PIPE,
+ text=True,
+ check=False,
+ )
+ if ret.returncode:
+ raise ChannelError(
+ f'{shlex.join(cmd)} failed with exit code {ret.returncode}'
+ )
+ return ret.stdout
+
+
+class LocalChannel(Channel):
+ """Runs the scripts on this machine."""
+
+ def command(self, args: Sequence[str]) -> list[str]:
+ return ['sh', '-s', '--', *args]
+
+ def host(self) -> str:
+ return '127.0.0.1'
+
+
+class SshChannel(Channel):
+ """Runs the scripts on the 'remote' ssh destination.
+
+ 'ssh_options' are passed to ssh. The login must not ask for a password.
+ """
+
+ def _ssh(self) -> list[str]:
+ try:
+ remote = self.config['remote']
+ except KeyError:
+ raise ChannelError("ssh channel needs 'remote'") from None
+ return ['ssh', *self.config.get('ssh_options', []), remote]
+
+ def command(self, args: Sequence[str]) -> list[str]:
+ # ssh passes the command to the remote shell as one string.
+ return [*self._ssh(), shlex.join(['sh', '-s', '--', *args])]
+
+ def host(self) -> str:
+ # ssh -G prints the configuration for the destination without
+ # connecting, including the host name of an alias.
+ ssh = self._ssh()
+ ret = subprocess.run(
+ [ssh[0], '-G', *ssh[1:]],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ for line in ret.stdout.splitlines():
+ key, _, value = line.partition(' ')
+ if key == 'hostname':
+ return value
+ raise ChannelError(f'ssh -G {ssh[-1]} failed: {ret.stderr.strip()}')
+
+
+CHANNELS = {
+ 'local': f'{__name__}:LocalChannel',
+ 'ssh': f'{__name__}:SshChannel',
+}
+
+
+def create(config: dict[str, Any]) -> Channel:
+ """Create the channel named by config['channel'], ssh by default."""
+ cls = plugin.load('channel', config.get('channel', 'ssh'), CHANNELS)
+ if not (isinstance(cls, type) and issubclass(cls, Channel)):
+ raise plugin.PluginError(f'{cls!r} is not a Channel')
+ return cls(config)
diff --git a/contrib/nvme-target-control/nvme_target_control/cli.py b/contrib/nvme-target-control/nvme_target_control/cli.py
new file mode 100644
index 000000000000..2b49ab7bb4df
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/cli.py
@@ -0,0 +1,205 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""The command line interface blktests calls through NVME_TARGET_CONTROL."""
+
+import argparse
+import os
+import sys
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+from . import target
+from .channel import ChannelError
+from .plugin import PluginError
+
+if sys.version_info >= (3, 11):
+ import tomllib
+else:
+ import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
+
+
+def config_path() -> Path:
+ """Return the configuration file.
+
+ NVME_TARGET_CONTROL_CONFIG overrides the default
+ $XDG_CONFIG_HOME/blktests/nvme_target_control.toml.
+ """
+ path = os.environ.get('NVME_TARGET_CONTROL_CONFIG')
+ if path:
+ return Path(path)
+ base = os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config')
+ return Path(base) / 'blktests' / 'nvme_target_control.toml'
+
+
+def load_config(path: Path) -> dict[str, Any]:
+ with path.open('rb') as f:
+ config: dict[str, Any] = tomllib.load(f)
+ return config
+
+
+# The config --show-* options, named like the Target methods
+SHOW = [
+ 'blkdev_type',
+ 'trtype',
+ 'hostnqn',
+ 'hostid',
+ 'host_traddr',
+ 'traddr',
+ 'trsvid',
+ 'subsys_uuid',
+ 'subsysnqn',
+]
+
+
+def show(tgt: target.Target, args: argparse.Namespace) -> None:
+ if args.show_features:
+ print(' '.join(tgt.features()))
+ return
+ for option in SHOW:
+ if getattr(args, f'show_{option}'):
+ print(tgt.value(option))
+ return
+
+
+def subsystem(args: argparse.Namespace) -> target.Subsystem:
+ return target.Subsystem(
+ nqn=args.subsysnqn,
+ uuid=args.subsys_uuid,
+ hostnqn=args.hostnqn,
+ hostkey=args.hostkey,
+ ctrlkey=args.ctrlkey,
+ )
+
+
+def print_lines(lines: Sequence[str]) -> None:
+ for line in lines:
+ print(line)
+
+
+def run(config: dict[str, Any], args: argparse.Namespace) -> None:
+ tgt = target.create(config['target'])
+ if args.cmd == 'config':
+ show(tgt, args)
+ return
+ if config['target'].get('skip_setup_cleanup', False):
+ return
+
+ if args.cmd == 'setup':
+ tgt.setup(subsystem(args), args.ports, args.tls)
+ elif args.cmd == 'cleanup':
+ tgt.cleanup(args.subsysnqn)
+ elif args.cmd == 'add-ports':
+ print_lines(tgt.add_ports(args.subsysnqn, args.ports, args.tls))
+ elif args.cmd == 'add-port':
+ print(tgt.add_port(args.tls))
+ elif args.cmd == 'remove-port':
+ tgt.remove_port(args.port)
+ elif args.cmd == 'list-ports':
+ print_lines(tgt.list_ports(args.subsysnqn))
+ elif args.cmd == 'show-port':
+ port = tgt.show_port(args.port)
+ print(port.trtype, port.traddr, port.trsvcid)
+ elif args.cmd == 'set-ana':
+ tgt.set_ana(args.port, args.grpid, args.state)
+ elif args.cmd == 'set-host':
+ attrs = {
+ name: getattr(args, name)
+ for name in target.HOST_ATTRS
+ if getattr(args, name) is not None
+ }
+ tgt.set_host(args.hostnqn, attrs)
+
+
+def add_subsys_args(
+ parser: argparse.ArgumentParser, required: bool = True
+) -> None:
+ parser.add_argument('--subsysnqn', required=True)
+ parser.add_argument('--subsys-uuid', required=required)
+ parser.add_argument('--hostnqn', required=required)
+ parser.add_argument('--ctrlkey', default='')
+ parser.add_argument('--hostkey', default='')
+
+
+def add_port_args(parser: argparse.ArgumentParser, ports: bool) -> None:
+ """Add the options of the commands which create ports."""
+ if ports:
+ parser.add_argument('--ports', type=int, default=1)
+ tls = parser.add_mutually_exclusive_group()
+ # The options of _nvmet_target_setup in common/nvme
+ tls.add_argument(
+ '--tls',
+ action='store_const',
+ dest='tls',
+ const='not-required',
+ default='none',
+ )
+ tls.add_argument(
+ '--force-tls', action='store_const', dest='tls', const='required'
+ )
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog='nvme-target-control',
+ description='Set up and tear down remote NVMe targets for blktests.',
+ )
+ sub = parser.add_subparsers(dest='cmd', required=True)
+
+ setup = sub.add_parser('setup')
+ add_subsys_args(setup)
+ add_port_args(setup, ports=True)
+
+ cleanup = sub.add_parser('cleanup')
+ cleanup.add_argument('--subsysnqn', required=True)
+
+ # common/nvme passes the setup options to add-ports, only the
+ # subsystem NQN is used.
+ add_ports = sub.add_parser('add-ports')
+ add_subsys_args(add_ports, required=False)
+ add_port_args(add_ports, ports=True)
+
+ add_port_args(sub.add_parser('add-port'), ports=False)
+
+ sub.add_parser('remove-port').add_argument('--port', required=True)
+
+ sub.add_parser('list-ports').add_argument('--subsysnqn', required=True)
+
+ sub.add_parser('show-port').add_argument('--port', required=True)
+
+ set_ana = sub.add_parser('set-ana')
+ set_ana.add_argument('--port', required=True)
+ set_ana.add_argument('--grpid', type=int, required=True)
+ set_ana.add_argument('--state', required=True)
+
+ set_host = sub.add_parser('set-host')
+ set_host.add_argument('--hostnqn', required=True)
+ for name in target.HOST_ATTRS:
+ set_host.add_argument('--' + name)
+
+ cfg = sub.add_parser('config')
+ for option in SHOW:
+ cfg.add_argument(
+ '--show-' + option.replace('_', '-'), action='store_true'
+ )
+ cfg.add_argument('--show-features', action='store_true')
+
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ try:
+ run(load_config(config_path()), args)
+ except (
+ ChannelError,
+ PluginError,
+ NotImplementedError,
+ KeyError,
+ OSError,
+ ValueError,
+ tomllib.TOMLDecodeError,
+ ) as e:
+ print(f'nvme-target-control: {e!r}', file=sys.stderr)
+ return 1
+ return 0
diff --git a/contrib/nvme-target-control/nvme_target_control/plugin.py b/contrib/nvme-target-control/nvme_target_control/plugin.py
new file mode 100644
index 000000000000..a73ebf4665b1
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/plugin.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Look up target and channel implementations by name."""
+
+import importlib
+from typing import Any
+
+
+class PluginError(Exception):
+ """A plugin can't be found or loaded."""
+
+
+def load(kind: str, name: str, builtin: dict[str, str]) -> Any:
+ """Return the class for name.
+
+ name is either a key of builtin or 'package.module:Class', to load an
+ implementation from outside this package.
+ """
+ path = builtin.get(name, name)
+ module_name, sep, class_name = path.partition(':')
+ if not sep or not module_name or not class_name:
+ known = ', '.join(sorted(builtin))
+ raise PluginError(
+ f"unknown {kind} '{name}', use one of {known} or 'module:Class'"
+ )
+ try:
+ module = importlib.import_module(module_name)
+ except ImportError as e:
+ raise PluginError(f"can't load {kind} '{name}': {e}") from e
+ try:
+ return getattr(module, class_name)
+ except AttributeError as e:
+ raise PluginError(
+ f"{kind} '{name}': {module_name} has no {class_name}"
+ ) from e
diff --git a/contrib/nvme-target-control/nvme_target_control/target.py b/contrib/nvme-target-control/nvme_target_control/target.py
new file mode 100644
index 000000000000..d648826e0386
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/target.py
@@ -0,0 +1,221 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Targets configure a subsystem on a kind of NVMe target.
+
+A target implementation gets the [target] section of the configuration. It
+answers the values blktests asks for with 'config --show-*' and creates the
+subsystem blktests connects to. The built-in 'nvmet' target configures a
+Linux nvmet target. Other targets, e.g. a storage array with a management
+API, can be added as 'package.module:Class' in the [target] 'type' option.
+"""
+
+import socket
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, ClassVar
+
+from . import plugin
+
+# The defaults of blktests, see common/nvme
+DEF_HOSTID = '0f01fb42-9f7f-4856-b0b3-51e60b8de349'
+DEF_SUBSYSNQN = 'blktests-subsystem-1'
+DEF_SUBSYS_UUID = '91fdba0d-f87b-4c25-b80f-db7be1418b9e'
+DEF_TRSVCID = '4420'
+
+
+ at dataclass(frozen=True)
+class Subsystem:
+ """The subsystem blktests wants to connect to."""
+
+ nqn: str
+ uuid: str
+ hostnqn: str
+ hostkey: str = ''
+ ctrlkey: str = ''
+
+
+ at dataclass(frozen=True)
+class Port:
+ """The address of a port, what the host passes to nvme connect."""
+
+ trtype: str
+ traddr: str
+ trsvcid: str
+
+
+# The TLS modes of a port: no TLS, TLS optional, TLS required
+TLS_MODES = ('none', 'not-required', 'required')
+
+# The DH-CHAP attributes of a host: the host key, the controller key for
+# bidirectional authentication, the hash and the DH group, e.g.
+# 'hmac(sha256)' and 'ffdhe2048'
+HOST_ATTRS = ('hostkey', 'ctrlkey', 'hash', 'dhgroup')
+
+
+def read_first_line(path: str) -> str | None:
+ try:
+ return Path(path).read_text().split('\n', 1)[0].strip() or None
+ except OSError:
+ return None
+
+
+def resolve(host: str) -> str:
+ """Return the IP address of host."""
+ return str(
+ socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)[0][4][0]
+ )
+
+
+def source_address(addr: str) -> str:
+ """Return the local address of the route to addr."""
+ family = socket.AF_INET6 if ':' in addr else socket.AF_INET
+ with socket.socket(family, socket.SOCK_DGRAM) as s:
+ # Connecting a UDP socket sends nothing, it only picks the route.
+ s.connect((addr, 9))
+ return str(s.getsockname()[0])
+
+
+class Target(ABC):
+ """A kind of NVMe target.
+
+ config is the [target] section. Each value blktests asks for can be set
+ there with the name of the 'config --show-*' option, e.g. 'traddr'.
+ Otherwise the target works it out, see the methods below. They must not
+ contact the target: blktests asks for them many times per test.
+ """
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ self.config = config
+
+ def value(self, name: str) -> str:
+ """Return the value of 'config --show-NAME'."""
+ if name in self.config:
+ return str(self.config[name])
+ return str(getattr(self, name)())
+
+ def trtype(self) -> str:
+ return 'tcp'
+
+ @abstractmethod
+ def traddr(self) -> str:
+ """Return the address the host connects to."""
+
+ def trsvid(self) -> str:
+ return DEF_TRSVCID
+
+ def host_traddr(self) -> str:
+ return source_address(self.value('traddr'))
+
+ def hostnqn(self) -> str:
+ return (
+ read_first_line('/etc/nvme/hostnqn')
+ or f'nqn.2014-08.org.nvmexpress:uuid:{self.value("hostid")}'
+ )
+
+ def hostid(self) -> str:
+ return read_first_line('/etc/nvme/hostid') or DEF_HOSTID
+
+ def subsysnqn(self) -> str:
+ return DEF_SUBSYSNQN
+
+ def subsys_uuid(self) -> str:
+ return DEF_SUBSYS_UUID
+
+ def blkdev_type(self) -> str:
+ return 'device'
+
+ @abstractmethod
+ def setup(
+ self, subsys: Subsystem, nr_ports: int = 1, tls: str = 'none'
+ ) -> None:
+ """Create subsys with one namespace and export it on new ports.
+
+ tls is one of TLS_MODES.
+ """
+
+ @abstractmethod
+ def cleanup(self, subsysnqn: str) -> None:
+ """Remove the subsystem and whatever setup created for it."""
+
+ # The optional commands. Ports are named by IDs the target picks. A
+ # target leaves the commands it can't do unimplemented. blktests
+ # skips the tests which need a feature 'config --show-features'
+ # doesn't list. Without list-ports and show-port, it connects to
+ # traddr and trsvid.
+
+ # Features which are not commands, e.g. 'tls'
+ extra_features: ClassVar[tuple[str, ...]] = ()
+
+ def features(self) -> list[str]:
+ """Return the optional commands the target implements.
+
+ Followed by extra_features.
+ """
+ return [
+ cmd
+ for cmd, method in OPTIONAL_COMMANDS.items()
+ if getattr(type(self), method) is not getattr(Target, method)
+ ] + list(self.extra_features)
+
+ def _not_implemented(self, what: str) -> NotImplementedError:
+ return NotImplementedError(
+ f'{type(self).__name__} does not implement {what}'
+ )
+
+ def add_ports(
+ self, subsysnqn: str, nr_ports: int, tls: str = 'none'
+ ) -> list[str]:
+ """Export the subsystem on nr_ports new ports, return their IDs."""
+ raise self._not_implemented('add-ports')
+
+ def add_port(self, tls: str = 'none') -> str:
+ """Create a port which exports no subsystem, return its ID."""
+ raise self._not_implemented('add-port')
+
+ def remove_port(self, port: str) -> None:
+ """Remove the port."""
+ raise self._not_implemented('remove-port')
+
+ def list_ports(self, subsysnqn: str) -> list[str]:
+ """Return the IDs of the ports which export the subsystem."""
+ raise self._not_implemented('list-ports')
+
+ def show_port(self, port: str) -> Port:
+ """Return the address of the port."""
+ raise self._not_implemented('show-port')
+
+ def set_ana(self, port: str, grpid: int, state: str) -> None:
+ """Set the state of ANA group grpid on the port."""
+ raise self._not_implemented('set-ana')
+
+ def set_host(self, hostnqn: str, attrs: dict[str, str]) -> None:
+ """Change the DH-CHAP attributes of a host setup allowed.
+
+ attrs maps names of HOST_ATTRS to their new values.
+ """
+ raise self._not_implemented('set-host')
+
+
+# The optional commands and the Target methods implementing them
+OPTIONAL_COMMANDS = {
+ 'add-ports': 'add_ports',
+ 'add-port': 'add_port',
+ 'remove-port': 'remove_port',
+ 'list-ports': 'list_ports',
+ 'show-port': 'show_port',
+ 'set-ana': 'set_ana',
+ 'set-host': 'set_host',
+}
+
+TARGETS = {
+ 'nvmet': 'nvme_target_control.targets.nvmet:NvmetTarget',
+}
+
+
+def create(config: dict[str, Any]) -> Target:
+ """Create the target the [target] section describes."""
+ cls = plugin.load('target', config.get('type', 'nvmet'), TARGETS)
+ if not (isinstance(cls, type) and issubclass(cls, Target)):
+ raise plugin.PluginError(f'{cls!r} is not a Target')
+ return cls(config)
diff --git a/contrib/nvme-target-control/nvme_target_control/targets/__init__.py b/contrib/nvme-target-control/nvme_target_control/targets/__init__.py
new file mode 100644
index 000000000000..99f346b65556
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/targets/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Built-in target implementations."""
diff --git a/contrib/nvme-target-control/nvme_target_control/targets/nvmet.py b/contrib/nvme-target-control/nvme_target_control/targets/nvmet.py
new file mode 100644
index 000000000000..f5f2e8cc2a62
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/targets/nvmet.py
@@ -0,0 +1,107 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""The Linux nvmet target, configured through configfs.
+
+The configfs changes are in the shell script nvmet.sh next to this file.
+It runs on the target through a channel, ssh by default. [target] options:
+
+ channel how to reach the target: 'ssh' (default), 'local' or
+ 'package.module:Class'
+ remote ssh destination, e.g. 'root at nvmet.local'
+ ssh_options list of extra ssh arguments
+ blkdev block device or file backing the namespace; by default
+ setup creates a loop device on an image file on the target
+ and cleanup removes both
+ img_size size of the image file, default 1G
+
+A new port gets the lowest free port ID N >= 1 and the service ID
+trsvid + N - 1, so the first port listens on trsvid. The values of
+'config --show-*' can be set too, see Target.
+"""
+
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+from .. import channel
+from ..target import Port, Subsystem, Target, resolve
+
+SCRIPT = Path(__file__).resolve().parent / 'nvmet.sh'
+
+# The configfs attributes of the HOST_ATTRS
+HOST_ATTRS = {
+ 'hostkey': 'dhchap_key',
+ 'ctrlkey': 'dhchap_ctrl_key',
+ 'hash': 'dhchap_hash',
+ 'dhgroup': 'dhchap_dhgroup',
+}
+
+
+class NvmetTarget(Target):
+ """Linux nvmet target."""
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ super().__init__(config)
+ self.channel = channel.create(config)
+
+ def traddr(self) -> str:
+ return resolve(self.channel.host())
+
+ def _run(self, *args: str) -> list[str]:
+ """Run an nvmet.sh command, return the words it printed."""
+ return self.channel.run(SCRIPT.read_text(), args).split()
+
+ def _port_args(self, tls: str) -> Sequence[str]:
+ return (
+ self.value('trtype'),
+ self.value('traddr'),
+ self.value('trsvid'),
+ tls,
+ )
+
+ def setup(
+ self, subsys: Subsystem, nr_ports: int = 1, tls: str = 'none'
+ ) -> None:
+ self._run(
+ 'setup',
+ *self._port_args(tls),
+ str(nr_ports),
+ subsys.nqn,
+ subsys.uuid,
+ str(self.config.get('blkdev', '')),
+ str(self.config.get('img_size', '1G')),
+ subsys.hostnqn,
+ subsys.hostkey,
+ subsys.ctrlkey,
+ )
+
+ def cleanup(self, subsysnqn: str) -> None:
+ self._run('cleanup', subsysnqn)
+
+ def add_ports(
+ self, subsysnqn: str, nr_ports: int, tls: str = 'none'
+ ) -> list[str]:
+ return self._run(
+ 'add-ports', *self._port_args(tls), str(nr_ports), subsysnqn
+ )
+
+ def add_port(self, tls: str = 'none') -> str:
+ (port,) = self._run('add-port', *self._port_args(tls))
+ return port
+
+ def remove_port(self, port: str) -> None:
+ self._run('remove-port', port)
+
+ def list_ports(self, subsysnqn: str) -> list[str]:
+ return self._run('list-ports', subsysnqn)
+
+ def show_port(self, port: str) -> Port:
+ trtype, traddr, trsvcid = self._run('show-port', port)
+ return Port(trtype, traddr, trsvcid)
+
+ def set_ana(self, port: str, grpid: int, state: str) -> None:
+ self._run('set-ana', port, str(grpid), state)
+
+ def set_host(self, hostnqn: str, attrs: dict[str, str]) -> None:
+ for name, value in attrs.items():
+ self._run('set-host', hostnqn, HOST_ATTRS[name], value)
diff --git a/contrib/nvme-target-control/nvme_target_control/targets/nvmet.sh b/contrib/nvme-target-control/nvme_target_control/targets/nvmet.sh
new file mode 100644
index 000000000000..33f383eb17f0
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/targets/nvmet.sh
@@ -0,0 +1,246 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+#
+# Configure subsystems and ports of a Linux nvmet target through configfs.
+# Runs on the target.
+#
+# Usage: nvmet.sh COMMAND ARGS...
+#
+# setup TRTYPE TRADDR TRSVCID TLS NR_PORTS SUBSYSNQN SUBSYS_UUID BLKDEV
+# IMG_SIZE HOSTNQN HOSTKEY CTRLKEY
+# Create a subsystem with namespace 1, allow one host and export the
+# subsystem on NR_PORTS new ports. Without BLKDEV, the namespace is a
+# loop device on an IMG_SIZE image file, cleanup removes both.
+# cleanup SUBSYSNQN
+# Remove a subsystem, the ports which exported it and no other
+# subsystem, the hosts no other subsystem allows and the loop device
+# and image file setup created.
+# add-ports TRTYPE TRADDR TRSVCID TLS NR_PORTS SUBSYSNQN
+# Export a subsystem on NR_PORTS new ports, print their IDs.
+# add-port TRTYPE TRADDR TRSVCID TLS
+# Create a port which exports no subsystem, print its ID.
+# remove-port PORT
+# Remove a port and the subsystem links and ANA groups on it.
+# list-ports SUBSYSNQN
+# Print the IDs of the ports which export a subsystem.
+# show-port PORT
+# Print the transport type, address and service ID of a port.
+# set-ana PORT GRPID STATE
+# Set the state of an ANA group on a port, create the group if needed.
+# set-host HOSTNQN ATTR VALUE
+# Set a DH-CHAP attribute of a host: dhchap_key, dhchap_ctrl_key,
+# dhchap_hash or dhchap_dhgroup.
+#
+# A new port gets the lowest free port ID N >= 1 and the service ID
+# TRSVCID + N - 1. TLS is none, not-required or required.
+
+set -e
+
+cfs=/sys/kernel/config/nvmet
+state=/var/tmp/nvme-target-control
+
+die() {
+ echo "nvmet.sh: $*" >&2
+ exit 1
+}
+
+# add_port TRTYPE TRADDR TRSVCID TLS: create a port, set its ID in $port
+add_port() {
+ trtype=$1
+ traddr=$2
+ trsvcid=$3
+ tls=$4
+
+ modprobe nvmet
+ modprobe "nvmet-${trtype}"
+
+ port=1
+ while [ -e "${cfs}/ports/${port}" ]; do
+ port=$((port + 1))
+ done
+ case "${traddr}" in
+ *:*) adrfam=ipv6 ;;
+ *) adrfam=ipv4 ;;
+ esac
+
+ mkdir "${cfs}/ports/${port}"
+ echo "${trtype}" > "${cfs}/ports/${port}/addr_trtype"
+ echo "${adrfam}" > "${cfs}/ports/${port}/addr_adrfam"
+ echo "${traddr}" > "${cfs}/ports/${port}/addr_traddr"
+ echo $((trsvcid + port - 1)) > "${cfs}/ports/${port}/addr_trsvcid"
+ case "${tls}" in
+ none)
+ ;;
+ not-required|required)
+ echo tls1.3 > "${cfs}/ports/${port}/addr_tsas"
+ if [ "${tls}" = not-required ]; then
+ echo "not required" > "${cfs}/ports/${port}/addr_treq"
+ fi
+ ;;
+ *)
+ die "unknown TLS mode: ${tls}"
+ ;;
+ esac
+}
+
+# add_ports TRTYPE TRADDR TRSVCID TLS NR_PORTS SUBSYSNQN
+add_ports() {
+ [ -d "${cfs}/subsystems/$6" ] || die "no subsystem $6"
+ i=0
+ while [ "${i}" -lt "$5" ]; do
+ add_port "$1" "$2" "$3" "$4"
+ # Linking the first subsystem enables the port.
+ ln -s "${cfs}/subsystems/$6" "${cfs}/ports/${port}/subsystems/$6"
+ echo "${port}"
+ i=$((i + 1))
+ done
+}
+
+remove_port() {
+ p="${cfs}/ports/$1"
+ [ -d "${p}" ] || die "no port $1"
+ for link in "${p}"/subsystems/*; do
+ [ -L "${link}" ] && rm "${link}"
+ done
+ for grp in "${p}"/ana_groups/*; do
+ # ANA group 1 belongs to the port.
+ [ "${grp##*/}" = 1 ] || rmdir "${grp}"
+ done
+ rmdir "${p}"
+}
+
+setup() {
+ trtype=$1
+ traddr=$2
+ trsvcid=$3
+ tls=$4
+ nr_ports=$5
+ subsysnqn=$6
+ subsys_uuid=$7
+ blkdev=$8
+ img_size=$9
+ hostnqn=${10}
+ hostkey=${11}
+ ctrlkey=${12}
+
+ modprobe nvmet
+
+ if [ -z "${blkdev}" ]; then
+ mkdir -p "${state}"
+ img="${state}/${subsysnqn}.img"
+ rm -f "${img}"
+ truncate -s "${img_size}" "${img}"
+ blkdev=$(losetup -f --show "${img}")
+ echo "${blkdev}" > "${state}/${subsysnqn}.loop"
+ fi
+
+ host="${cfs}/hosts/${hostnqn}"
+ mkdir -p "${host}"
+ if [ -n "${hostkey}" ]; then
+ echo "${hostkey}" > "${host}/dhchap_key"
+ fi
+ if [ -n "${ctrlkey}" ]; then
+ echo "${ctrlkey}" > "${host}/dhchap_ctrl_key"
+ fi
+
+ subsys="${cfs}/subsystems/${subsysnqn}"
+ mkdir "${subsys}"
+ echo 0 > "${subsys}/attr_allow_any_host"
+ mkdir "${subsys}/namespaces/1"
+ echo "${blkdev}" > "${subsys}/namespaces/1/device_path"
+ echo "${subsys_uuid}" > "${subsys}/namespaces/1/device_uuid"
+ echo 1 > "${subsys}/namespaces/1/enable"
+ ln -s "${host}" "${subsys}/allowed_hosts/${hostnqn}"
+
+ add_ports "${trtype}" "${traddr}" "${trsvcid}" "${tls}" \
+ "${nr_ports}" "${subsysnqn}" > /dev/null
+}
+
+cleanup() {
+ subsysnqn=$1
+ subsys="${cfs}/subsystems/${subsysnqn}"
+
+ if [ -d "${subsys}" ]; then
+ for p in "${cfs}"/ports/*; do
+ [ -L "${p}/subsystems/${subsysnqn}" ] || continue
+ rm "${p}/subsystems/${subsysnqn}"
+ if [ -z "$(ls "${p}/subsystems")" ]; then
+ remove_port "${p##*/}"
+ fi
+ done
+
+ for link in "${subsys}"/allowed_hosts/*; do
+ [ -L "${link}" ] || continue
+ hostnqn=$(basename "${link}")
+ rm "${link}"
+ if ! ls "${cfs}"/subsystems/*/allowed_hosts/"${hostnqn}" \
+ > /dev/null 2>&1; then
+ rmdir "${cfs}/hosts/${hostnqn}"
+ fi
+ done
+
+ for ns in "${subsys}"/namespaces/*; do
+ [ -d "${ns}" ] || continue
+ echo 0 > "${ns}/enable"
+ rmdir "${ns}"
+ done
+
+ rmdir "${subsys}"
+ fi
+
+ if [ -f "${state}/${subsysnqn}.loop" ]; then
+ losetup -d "$(cat "${state}/${subsysnqn}.loop")"
+ rm -f "${state}/${subsysnqn}.loop" "${state}/${subsysnqn}.img"
+ fi
+}
+
+list_ports() {
+ for p in "${cfs}"/ports/*; do
+ [ -L "${p}/subsystems/$1" ] && echo "${p##*/}"
+ done
+ return 0
+}
+
+show_port() {
+ p="${cfs}/ports/$1"
+ [ -d "${p}" ] || die "no port $1"
+ echo "$(cat "${p}/addr_trtype") $(cat "${p}/addr_traddr")" \
+ "$(cat "${p}/addr_trsvcid")"
+}
+
+set_ana() {
+ p="${cfs}/ports/$1"
+ [ -d "${p}" ] || die "no port $1"
+ grp="${p}/ana_groups/$2"
+ [ -d "${grp}" ] || mkdir "${grp}"
+ echo "$3" > "${grp}/ana_state"
+}
+
+set_host() {
+ h="${cfs}/hosts/$1"
+ [ -d "${h}" ] || die "no host $1"
+ case "$2" in
+ dhchap_key|dhchap_ctrl_key|dhchap_hash|dhchap_dhgroup)
+ echo "$3" > "${h}/$2"
+ ;;
+ *)
+ die "unknown host attribute: $2"
+ ;;
+ esac
+}
+
+cmd=$1
+shift
+case "${cmd}" in
+setup) setup "$@" ;;
+cleanup) cleanup "$@" ;;
+add-ports) add_ports "$@" ;;
+add-port) add_port "$@"; echo "${port}" ;;
+remove-port) remove_port "$@" ;;
+list-ports) list_ports "$@" ;;
+show-port) show_port "$@" ;;
+set-ana) set_ana "$@" ;;
+set-host) set_host "$@" ;;
+*) die "unknown command: ${cmd}" ;;
+esac
diff --git a/contrib/nvme-target-control/pyproject.toml b/contrib/nvme-target-control/pyproject.toml
new file mode 100644
index 000000000000..2445637e9bd4
--- /dev/null
+++ b/contrib/nvme-target-control/pyproject.toml
@@ -0,0 +1,42 @@
+# SPDX-License-Identifier: GPL-3.0+
+#
+# The linters for nvme-target-control: ruff check and mypy, run from this
+# directory. The tests: python3 -m unittest discover -s tests
+
+[project]
+name = "nvme-target-control"
+version = "0.1.0"
+description = "Set up and tear down remote NVMe targets for blktests"
+requires-python = ">=3.10"
+license = { text = "GPL-3.0-or-later" }
+# Pure stdlib, Python < 3.11 needs tomli
+dependencies = ["tomli; python_version < '3.11'"]
+
+[tool.ruff]
+line-length = 79
+target-version = "py310"
+extend-include = ["nvme-target-control"]
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle
+ "W",
+ "F", # pyflakes
+ "I", # isort
+ "B", # bugbear
+ "UP", # pyupgrade
+ "SIM", # simplify
+ "RUF",
+]
+
+[tool.ruff.format]
+quote-style = "single"
+
+[tool.mypy]
+python_version = "3.10"
+strict = true
+files = ["nvme_target_control", "tests"]
+
+[tool.ruff.lint.per-file-ignores]
+# its start runs with old Pythons, to report the version
+"nvme-target-control" = ["UP036"]
diff --git a/contrib/nvme-target-control/tests/test_target_control.py b/contrib/nvme-target-control/tests/test_target_control.py
new file mode 100644
index 000000000000..730d6c8a9b69
--- /dev/null
+++ b/contrib/nvme-target-control/tests/test_target_control.py
@@ -0,0 +1,380 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Tests for the command line, the plugin lookup and the nvmet target."""
+
+import contextlib
+import io
+import os
+import shlex
+import sys
+import tempfile
+import types
+import unittest
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any, ClassVar
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from nvme_target_control import channel, cli, plugin, target
+from nvme_target_control.targets import nvmet
+
+CONFIG = """\
+[target]
+remote = "root at 127.0.0.1"
+"""
+
+SETUP_ARGS = [
+ 'setup',
+ '--subsysnqn', 'blktests-subsystem-1',
+ '--subsys-uuid', '91fdba0d-f87b-4c25-b80f-db7be1418b9e',
+ '--hostnqn', 'nqn.2014-08.org.nvmexpress:uuid:57c34e91',
+] # fmt: skip
+
+
+class RecordingTarget(target.Target):
+ """A target plugin which records the calls."""
+
+ calls: ClassVar[list[tuple[str, Any]]] = []
+
+ def traddr(self) -> str:
+ return '192.0.2.1'
+
+ def setup(
+ self, subsys: target.Subsystem, nr_ports: int = 1, tls: str = 'none'
+ ) -> None:
+ self.calls.append(('setup', (subsys, nr_ports, tls)))
+
+ def cleanup(self, subsysnqn: str) -> None:
+ self.calls.append(('cleanup', subsysnqn))
+
+
+PLUGIN = """\
+[target]
+type = "fake_array:RecordingTarget"
+"""
+
+
+class ConfigTestCase(unittest.TestCase):
+ def setUp(self) -> None:
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.config = Path(tmp.name) / 'config.toml'
+ self.config.write_text(CONFIG)
+ env = mock.patch.dict(
+ os.environ, {'NVME_TARGET_CONTROL_CONFIG': str(self.config)}
+ )
+ env.start()
+ self.addCleanup(env.stop)
+ module = types.ModuleType('fake_array')
+ module.RecordingTarget = RecordingTarget # type: ignore[attr-defined]
+ modules = mock.patch.dict(sys.modules, {'fake_array': module})
+ modules.start()
+ self.addCleanup(modules.stop)
+ RecordingTarget.calls = []
+
+ def main(self, argv: Sequence[str]) -> tuple[int, str, str]:
+ out, err = io.StringIO(), io.StringIO()
+ with (
+ contextlib.redirect_stdout(out),
+ contextlib.redirect_stderr(err),
+ ):
+ ret = cli.main(argv)
+ return ret, out.getvalue(), err.getvalue()
+
+ def show(self, option: str) -> str:
+ ret, out, err = self.main(['config', f'--show-{option}'])
+ self.assertEqual((ret, err), (0, ''))
+ return out.rstrip('\n')
+
+
+class TestCli(ConfigTestCase):
+ def test_defaults(self) -> None:
+ self.assertEqual(self.show('traddr'), '127.0.0.1')
+ self.assertEqual(self.show('host-traddr'), '127.0.0.1')
+ self.assertEqual(self.show('trtype'), 'tcp')
+ self.assertEqual(self.show('trsvid'), '4420')
+ self.assertEqual(self.show('subsysnqn'), 'blktests-subsystem-1')
+ self.assertEqual(
+ self.show('subsys-uuid'), '91fdba0d-f87b-4c25-b80f-db7be1418b9e'
+ )
+ self.assertEqual(self.show('blkdev-type'), 'device')
+
+ def test_host_identity(self) -> None:
+ files = {
+ '/etc/nvme/hostid': 'abc',
+ '/etc/nvme/hostnqn': 'nqn.test:host',
+ }
+ with mock.patch.object(target, 'read_first_line', files.get):
+ self.assertEqual(self.show('hostid'), 'abc')
+ self.assertEqual(self.show('hostnqn'), 'nqn.test:host')
+ with mock.patch.object(target, 'read_first_line', lambda _: None):
+ self.assertEqual(self.show('hostid'), target.DEF_HOSTID)
+ self.assertEqual(
+ self.show('hostnqn'),
+ f'nqn.2014-08.org.nvmexpress:uuid:{target.DEF_HOSTID}',
+ )
+
+ def test_overrides(self) -> None:
+ self.config.write_text(
+ CONFIG + 'traddr = "10.9.9.9"\ntrsvid = 4421\nhostid = "x"\n'
+ )
+ self.assertEqual(self.show('traddr'), '10.9.9.9')
+ self.assertEqual(self.show('trsvid'), '4421')
+ self.assertEqual(self.show('hostid'), 'x')
+
+ def test_external_target_plugin(self) -> None:
+ self.config.write_text(PLUGIN)
+ self.assertEqual(self.show('traddr'), '192.0.2.1')
+ ret, _, err = self.main([*SETUP_ARGS, '--ctrlkey', 'ckey'])
+ self.assertEqual((ret, err), (0, ''))
+ ret, _, err = self.main(
+ ['cleanup', '--subsysnqn', 'blktests-subsystem-1']
+ )
+ self.assertEqual((ret, err), (0, ''))
+ (cmd, (subsys, nr_ports, tls)), (cmd2, nqn) = RecordingTarget.calls
+ self.assertEqual(cmd, 'setup')
+ self.assertEqual(subsys.nqn, 'blktests-subsystem-1')
+ self.assertEqual(subsys.ctrlkey, 'ckey')
+ self.assertEqual((nr_ports, tls), (1, 'none'))
+ self.assertEqual((cmd2, nqn), ('cleanup', 'blktests-subsystem-1'))
+
+ def test_setup_ports_tls(self) -> None:
+ self.config.write_text(PLUGIN)
+ ret, _, err = self.main([*SETUP_ARGS, '--ports', '3', '--tls'])
+ self.assertEqual((ret, err), (0, ''))
+ ret, _, err = self.main([*SETUP_ARGS, '--force-tls'])
+ self.assertEqual((ret, err), (0, ''))
+ (_, (_, n1, tls1)), (_, (_, n2, tls2)) = RecordingTarget.calls
+ self.assertEqual(
+ (n1, tls1, n2, tls2), (3, 'not-required', 1, 'required')
+ )
+ with (
+ contextlib.redirect_stderr(io.StringIO()),
+ self.assertRaises(SystemExit),
+ ):
+ cli.main([*SETUP_ARGS, '--tls', '--force-tls'])
+
+ def test_features(self) -> None:
+ self.assertEqual(
+ self.show('features'),
+ 'add-ports add-port remove-port list-ports show-port set-ana '
+ 'set-host',
+ )
+ self.config.write_text(PLUGIN)
+ self.assertEqual(self.show('features'), '')
+
+ def test_set_host(self) -> None:
+ with mock.patch.object(nvmet.NvmetTarget, 'set_host') as set_host:
+ ret, _, err = self.main(
+ ['set-host', '--hostnqn', 'nqn.h', '--dhgroup', 'ffdhe2048']
+ )
+ self.assertEqual((ret, err), (0, ''))
+ set_host.assert_called_once_with('nqn.h', {'dhgroup': 'ffdhe2048'})
+
+ def test_port_output(self) -> None:
+ with (
+ mock.patch.object(
+ nvmet.NvmetTarget,
+ 'show_port',
+ return_value=target.Port('tcp', '10.0.0.1', '4421'),
+ ),
+ mock.patch.object(
+ nvmet.NvmetTarget, 'add_ports', return_value=['2', '3']
+ ),
+ ):
+ ret, out, _ = self.main(['show-port', '--port', '2'])
+ self.assertEqual((ret, out), (0, 'tcp 10.0.0.1 4421\n'))
+ ret, out, _ = self.main(
+ ['add-ports', '--ports', '2', '--subsysnqn', 'nqn.s']
+ )
+ self.assertEqual((ret, out), (0, '2\n3\n'))
+
+ def test_ports_not_implemented(self) -> None:
+ self.config.write_text(PLUGIN)
+ for argv in [
+ ['add-ports', '--ports', '2', *SETUP_ARGS[1:]],
+ ['add-port'],
+ ['remove-port', '--port', '1'],
+ ['list-ports', '--subsysnqn', 'nqn.s'],
+ ['show-port', '--port', '1'],
+ ['set-ana', '--port', '1', '--grpid', '1', '--state', 'optimized'],
+ ['set-host', '--hostnqn', 'nqn.h', '--hash', 'hmac(sha256)'],
+ ]:
+ ret, out, err = self.main(argv)
+ self.assertEqual((ret, out), (1, ''))
+ self.assertIn(f'does not implement {argv[0]}', err)
+
+ def test_skip_setup_cleanup(self) -> None:
+ self.config.write_text(PLUGIN + 'skip_setup_cleanup = true\n')
+ ret, _, err = self.main(SETUP_ARGS)
+ self.assertEqual((ret, err), (0, ''))
+ self.assertEqual(RecordingTarget.calls, [])
+
+ def test_unknown_target(self) -> None:
+ self.config.write_text('[target]\ntype = "foo"\n')
+ ret, _, err = self.main(SETUP_ARGS)
+ self.assertEqual(ret, 1)
+ self.assertIn("unknown target 'foo'", err)
+
+ def test_missing_config(self) -> None:
+ self.config.unlink()
+ ret, _, err = self.main(SETUP_ARGS)
+ self.assertEqual(ret, 1)
+ self.assertIn('FileNotFoundError', err)
+
+
+class TestPlugin(unittest.TestCase):
+ def test_builtin_and_path(self) -> None:
+ self.assertIs(
+ plugin.load('channel', 'ssh', channel.CHANNELS),
+ channel.SshChannel,
+ )
+ self.assertIs(
+ plugin.load(
+ 'channel',
+ 'nvme_target_control.channel:LocalChannel',
+ channel.CHANNELS,
+ ),
+ channel.LocalChannel,
+ )
+
+ def test_errors(self) -> None:
+ for name in ['foo', 'no_such_module:Foo', 'nvme_target_control:Foo']:
+ with self.assertRaises(plugin.PluginError):
+ plugin.load('channel', name, channel.CHANNELS)
+
+ def test_wrong_base_class(self) -> None:
+ with self.assertRaises(plugin.PluginError):
+ channel.create({'channel': 'nvme_target_control.target:Target'})
+
+
+class TestChannel(unittest.TestCase):
+ def test_ssh_command(self) -> None:
+ ch = channel.SshChannel(
+ {'remote': 'root at t', 'ssh_options': ['-p', '22']}
+ )
+ cmd = ch.command(['nqn with space', "it's", ''])
+ self.assertEqual(cmd[:4], ['ssh', '-p', '22', 'root at t'])
+ # The remote shell splits the command back into the arguments.
+ self.assertEqual(
+ shlex.split(cmd[4]),
+ ['sh', '-s', '--', 'nqn with space', "it's", ''],
+ )
+
+ def test_ssh_host(self) -> None:
+ ch = channel.SshChannel({'remote': 'root at 192.0.2.7'})
+ self.assertEqual(ch.host(), '192.0.2.7')
+
+ def test_ssh_needs_remote(self) -> None:
+ with self.assertRaises(channel.ChannelError):
+ channel.SshChannel({}).command([])
+
+ def test_local_run(self) -> None:
+ out = channel.LocalChannel({}).run('printf "%s|" "$@"', ['a b', ''])
+ self.assertEqual(out, 'a b||')
+
+ def test_local_run_fails(self) -> None:
+ with self.assertRaises(channel.ChannelError):
+ channel.LocalChannel({}).run('exit 3', [])
+
+
+class FakeChannel(channel.Channel):
+ runs: ClassVar[list[tuple[str, list[str]]]] = []
+
+ def command(self, args: Sequence[str]) -> list[str]:
+ return []
+
+ def host(self) -> str:
+ return '192.0.2.5'
+
+ output: ClassVar[str] = ''
+
+ def run(self, script: str, args: Sequence[str]) -> str:
+ self.runs.append((script, list(args)))
+ return self.output
+
+
+class TestNvmet(unittest.TestCase):
+ def target(
+ self, config: dict[str, Any], output: str = ''
+ ) -> nvmet.NvmetTarget:
+ FakeChannel.runs = []
+ FakeChannel.output = output
+ return nvmet.NvmetTarget(
+ {'channel': f'{__name__}:FakeChannel', **config}
+ )
+
+ def test_traddr_from_channel(self) -> None:
+ self.assertEqual(self.target({}).value('traddr'), '192.0.2.5')
+
+ def test_setup(self) -> None:
+ tgt = self.target({})
+ tgt.setup(target.Subsystem('nqn.s', 'uuid', 'nqn.h', 'hk', 'ck'))
+ ((script, args),) = FakeChannel.runs
+ self.assertIn('/sys/kernel/config/nvmet', script)
+ self.assertEqual(
+ args,
+ ['setup', 'tcp', '192.0.2.5', '4420', 'none', '1', 'nqn.s',
+ 'uuid', '', '1G', 'nqn.h', 'hk', 'ck'],
+ ) # fmt: skip
+
+ def test_setup_options(self) -> None:
+ tgt = self.target(
+ {'blkdev': '/dev/nullb0', 'traddr': '10.0.0.1', 'trsvid': 4430}
+ )
+ tgt.setup(target.Subsystem('nqn.s', 'uuid', 'nqn.h'), 2, 'required')
+ ((_, args),) = FakeChannel.runs
+ self.assertEqual(
+ args[2:6] + args[8:9],
+ ['10.0.0.1', '4430', 'required', '2', '/dev/nullb0'],
+ )
+
+ def test_cleanup(self) -> None:
+ tgt = self.target({})
+ tgt.cleanup('nqn.s')
+ ((script, args),) = FakeChannel.runs
+ self.assertIn('losetup -d', script)
+ self.assertEqual(args, ['cleanup', 'nqn.s'])
+
+ def test_ports(self) -> None:
+ port_args = ['tcp', '192.0.2.5', '4420', 'none']
+ tgt = self.target({}, '2\n3\n')
+ self.assertEqual(tgt.add_ports('nqn.s', 2), ['2', '3'])
+ self.assertEqual(tgt.list_ports('nqn.s'), ['2', '3'])
+ self.assertEqual(
+ [args for _, args in FakeChannel.runs],
+ [['add-ports', *port_args, '2', 'nqn.s'],
+ ['list-ports', 'nqn.s']],
+ ) # fmt: skip
+
+ tgt = self.target({}, '4\n')
+ self.assertEqual(tgt.add_port('not-required'), '4')
+ tgt.remove_port('4')
+ tgt.set_ana('4', 2, 'inaccessible')
+ self.assertEqual(
+ [args for _, args in FakeChannel.runs],
+ [['add-port', 'tcp', '192.0.2.5', '4420', 'not-required'],
+ ['remove-port', '4'],
+ ['set-ana', '4', '2', 'inaccessible']],
+ ) # fmt: skip
+
+ tgt = self.target({})
+ tgt.set_host(
+ 'nqn.h', {'ctrlkey': 'DHHC-1:00:x:', 'hash': 'hmac(sha512)'}
+ )
+ self.assertEqual(
+ [args for _, args in FakeChannel.runs],
+ [['set-host', 'nqn.h', 'dhchap_ctrl_key', 'DHHC-1:00:x:'],
+ ['set-host', 'nqn.h', 'dhchap_hash', 'hmac(sha512)']],
+ ) # fmt: skip
+
+ tgt = self.target({}, 'tcp 10.0.0.1 4421\n')
+ self.assertEqual(
+ tgt.show_port('2'), target.Port('tcp', '10.0.0.1', '4421')
+ )
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/contrib/nvme_target_control.py b/contrib/nvme_target_control.py
deleted file mode 100755
index 7ad1e4519427..000000000000
--- a/contrib/nvme_target_control.py
+++ /dev/null
@@ -1,204 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: GPL-3.0+
-
-# blktests calls this script to setup/teardown remote targets. blktests passes
-# all relevant information via the command line, e.g. --hostnqn.
-#
-# This script uses nvmetcli to setup the remote target (it depends on the REST
-# API feature [1]). There is not technical need for nvmetcli to use but it makes
-# it simple to setup a remote Linux box. If you want to setup someting else
-# you should to replace this part.
-#
-# There are couple of global configuration options which need to be set.
-# Add ~/.config/blktests/nvme_target_control.toml file with something like:
-#
-# [main]
-# skip_setup_cleanup=false
-# nvmetcli='/usr/bin/nvmetcli'
-# remote='http://nvmet.local:5000'
-#
-# [host]
-# blkdev_type='device'
-# trtype='tcp'
-# hostnqn='nqn.2014-08.org.nvmexpress:uuid:0f01fb42-9f7f-4856-b0b3-51e60b8de349'
-# hostid='0f01fb42-9f7f-4856-b0b3-51e60b8de349'
-# host_traddr='192.168.154.187'
-#
-# [subsys_0]
-# traddr='192.168.19.189'
-# trsvid='4420'
-# subsysnqn='blktests-subsystem-1'
-# subsys_uuid='91fdba0d-f87b-4c25-b80f-db7be1418b9e'
-#
-# This expects nvmetcli with the restapi service running on target.
-#
-# Alternatively, you can skip the the target setup/cleanup completely
-# (skip_setup_cleanup) and run against a previously configured target.
-#
-# [main]
-# skip_setup_cleanup=true
-# nvmetcli='/usr/bin/nvmetcli'
-# remote='http://nvmet.local:5000'
-#
-# [host]
-# blkdev_type='device'
-# trtype='tcp'
-# hostnqn='nqn.2014-08.org.nvmexpress:uuid:1a9e23dd-466e-45ca-9f43-a29aaf47cb21'
-# hostid='1a9e23dd-466e-45ca-9f43-a29aaf47cb21'
-# host_traddr='10.161.16.48'
-#
-# [subsys_0]
-# traddr='10.162.198.45'
-# trsvid='4420'
-# subsysnqn='nqn.1988-11.com.dell:powerstore:00:f03028e73ef7D032D81E'
-# subsys_uuid='3a5c104c-ee41-38a1-8ccf-0968003d54e7'
-# blkdev='/dev/nullb0'
-#
-# nvmetcli uses JSON configuration, thus this script creates a JSON configuration
-# using a jinja2 template. After this step we simple have to set the blktests
-# variable correctly and start blktests.
-#
-# NVME_TARGET_CONTROL=~/blktests/contrib/nvme_target_control.py ./check nvme
-#
-# [1] https://github.com/hreinecke/nvmetcli/tree/restapi
-
-import os
-
-# workaround for python<3.11
-TOML_OPEN_MODE="rb"
-try:
- import tomllib
-except ModuleNotFoundError:
- import pip._vendor.tomli as tomllib
- TOML_OPEN_MODE="r"
-
-import argparse
-import subprocess
-from jinja2 import Environment, FileSystemLoader
-
-
-XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME")
-if not XDG_CONFIG_HOME:
- XDG_CONFIG_HOME = os.environ.get('HOME') + '/.config'
-
-
-with open(f'{XDG_CONFIG_HOME}/blktests/nvme_target_control.toml', TOML_OPEN_MODE) as f:
- config = tomllib.load(f)
- nvmetcli = config['main']['nvmetcli']
- remote = config['main']['remote']
-
-
-def gen_conf(conf):
- basepath = os.path.dirname(__file__)
- environment = Environment(loader=FileSystemLoader(basepath))
- template = environment.get_template('nvmet-subsys.jinja2')
- filename = f'{conf["subsysnqn"]}.json'
- content = template.render(conf)
- with open(filename, mode='w', encoding='utf-8') as outfile:
- outfile.write(content)
-
-
-def target_setup(args):
- if config['main']['skip_setup_cleanup']:
- return
-
- conf = {
- 'subsysnqn': args.subsysnqn,
- 'subsys_uuid': args.subsys_uuid,
- 'hostnqn': args.hostnqn,
- 'allowed_hosts': args.hostnqn,
- 'ctrlkey': args.ctrlkey,
- 'hostkey': args.hostkey,
- 'blkdev': config['subsys_0']['blkdev'],
- }
-
- gen_conf(conf)
-
- subprocess.call(['python3', nvmetcli, '--remote=' + remote,
- 'restore', args.subsysnqn + '.json'])
-
-def target_add_ports(_):
- if config['main']['skip_setup_cleanup']:
- return
-
- print("Warning: add-ports is not implemented yet.")
-
-def target_cleanup(args):
- if config['main']['skip_setup_cleanup']:
- return
-
- subprocess.call(['python3', nvmetcli, '--remote=' + remote,
- 'clear', args.subsysnqn + '.json'])
-
-
-def target_config(args):
- if args.show_blkdev_type:
- print(config['host']['blkdev_type'])
- elif args.show_trtype:
- print(config['host']['trtype'])
- elif args.show_hostnqn:
- print(config['host']['hostnqn'])
- elif args.show_hostid:
- print(config['host']['hostid'])
- elif args.show_host_traddr:
- print(config['host']['host_traddr'])
- elif args.show_traddr:
- print(config['subsys_0']['traddr'])
- elif args.show_trsvid:
- print(config['subsys_0']['trsvid'])
- elif args.show_subsysnqn:
- print(config['subsys_0']['subsysnqn'])
- elif args.show_subsys_uuid:
- print(config['subsys_0']['subsys_uuid'])
-
-
-def build_parser():
- parser = argparse.ArgumentParser()
- sub = parser.add_subparsers(required=True)
-
- setup = sub.add_parser('setup')
- setup.add_argument('--subsysnqn', required=True)
- setup.add_argument('--subsys-uuid', required=True)
- setup.add_argument('--hostnqn', required=True)
- setup.add_argument('--ctrlkey', default='')
- setup.add_argument('--hostkey', default='')
- setup.set_defaults(func=target_setup)
-
- setup = sub.add_parser('add-ports')
- setup.add_argument('--ports', required=True)
- setup.add_argument('--subsysnqn', required=True)
- setup.add_argument('--subsys-uuid', required=True)
- setup.add_argument('--hostnqn', required=True)
- setup.add_argument('--ctrlkey', default='')
- setup.add_argument('--hostkey', default='')
- setup.set_defaults(func=target_add_ports)
-
- cleanup = sub.add_parser('cleanup')
- cleanup.add_argument('--subsysnqn', required=True)
- cleanup.set_defaults(func=target_cleanup)
-
- config = sub.add_parser('config')
- config.add_argument('--show-blkdev-type', action='store_true')
- config.add_argument('--show-trtype', action='store_true')
- config.add_argument('--show-hostnqn', action='store_true')
- config.add_argument('--show-hostid', action='store_true')
- config.add_argument('--show-host-traddr', action='store_true')
- config.add_argument('--show-traddr', action='store_true')
- config.add_argument('--show-trsvid', action='store_true')
- config.add_argument('--show-subsys-uuid', action='store_true')
- config.add_argument('--show-subsysnqn', action='store_true')
- config.set_defaults(func=target_config)
-
- return parser
-
-
-def main():
- import sys
-
- parser = build_parser()
- args = parser.parse_args()
- args.func(args)
-
-
-if __name__ == '__main__':
- main()
diff --git a/contrib/nvmet-subsys.jinja2 b/contrib/nvmet-subsys.jinja2
deleted file mode 100644
index a446fbd9b784..000000000000
--- a/contrib/nvmet-subsys.jinja2
+++ /dev/null
@@ -1,71 +0,0 @@
-{
- "hosts": [
- {
- "nqn": "{{ hostnqn }}"
- }
- ],
- "ports": [
- {
- "addr": {
- "adrfam": "ipv4",
- "traddr": "0.0.0.0",
- "treq": "not specified",
- "trsvcid": "4420",
- "trtype": "tcp",
- "tsas": "none"
- },
- "ana_groups": [
- {
- "ana": {
- "state": "optimized"
- },
- "grpid": 1
- }
- ],
- "param": {
- "inline_data_size": "16384",
- "pi_enable": "0"
- },
- "portid": 0,
- "referrals": [],
- "subsystems": [
- "{{ subsysnqn }}"
- ]
- }
- ],
- "subsystems": [
- {
- "allowed_hosts": [
- "{{ allowed_hosts }}"
- ],
- "attr": {
- "allow_any_host": "0",
- "cntlid_max": "65519",
- "cntlid_min": "1",
- "firmware": "yada",
- "ieee_oui": "0x000000",
- "model": "Linux",
- "pi_enable": "0",
- "qid_max": "128",
- "serial": "0c74361069d9db6c65ef",
- "version": "1.3"
- },
- "namespaces": [
- {
- "ana": {
- "grpid": "1"
- },
- "ana_grpid": 1,
- "device": {
- "nguid": "00000000-0000-0000-0000-000000000000",
- "path": "{{ blkdev }}",
- "uuid": "{{ subsys_uuid }}"
- },
- "enable": 1,
- "nsid": 1
- }
- ],
- "nqn": "{{ subsysnqn }}"
- }
- ]
-}
--
2.56.0
More information about the Linux-nvme
mailing list