Linux block layer
 help / color / mirror / Atom feed
* [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets
@ 2026-11-04 16:44 Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 01/10] common/nvme: pass --ctrlkey to the target control script Daniel Wagner
                   ` (10 more replies)
  0 siblings, 11 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

Running the nvme fabrics tests against a remote target with
NVME_TARGET_CONTROL doesn't work in current upstream:

- contrib/nvme_target_control.py configures the target with nvmetcli
  through a REST API which only exists in an nvmetcli branch that was
  never merged, and its nvmetcli client crashes with configshell-fb 2.0.
  The target is never set up and the tests fail to connect.

- With a target set up by other means, the tests pass without testing
  anything: _nvme_ns_ready waits for the namespaces in the local nvmet
  configfs, which doesn't exist for a remote target. _nvme_connect_subsys
  returns an error after every connect, the tests skip their I/O and the
  disconnect, and the harness reports leftover controllers.

- The tests which use more than one port configure the ports in the
  local nvmet configfs, and add-ports is a stub. The authentication
  tests change the host keys there too.

Patch 1 makes common/nvme pass the controller key as --ctrlkey, the
option the script interface documents, instead of --ctrkey.

Patch 2 makes _nvme_ns_ready wait for the namespace the controller
reports when NVME_TARGET_CONTROL is set.

Patch 3 fixes the subsystem check of _nvmet_target_add_ports for the
local target.

Patch 4 skips nvme/038, nvme/051, nvme/058, nvme/060 and nvme/065 with a
remote target. They need the local nvmet target.

Patch 5 skips nvme/029 if its largest I/O exceeds the maximum transfer
size, e.g. with 4 KiB blocks and a 1 MiB MDTS.

Patch 6 replaces the script with contrib/nvme-target-control, a
standalone project in the style of nvme-cli's scripts/fuzz. It keeps the
command line blktests calls. The configuration only says how to reach
the target:

  [target]
  remote = "root@10.0.0.1"

The values blktests reads with config --show-* are worked out on the
host, each can be overridden. The target is a plugin: the built-in nvmet
target writes to configfs over ssh and backs the namespace with a loop
device it creates and removes. Other targets, e.g. a storage array with a
management API, and other channels than ssh can be added without
changing the project. The nvmet target also creates, removes and lists
ports, sets their ANA state and changes the DH-CHAP attributes of the
host.

Patch 7 makes the port helpers of common/nvme use the port commands with
a remote target.

Patch 8 makes the host key helpers of tests/nvme/rc use the set-host
command with a remote target.

Patch 9 lets the tests skip if the remote target lacks a feature they
need. config --show-features lists the optional commands the target
implements, e.g. a storage array with fixed ports and ANA states managed
by the array lists neither add-port nor set-ana.

Patch 10 adds such a target: a NetApp ONTAP storage virtual machine,
configured through the ONTAP REST API. It shows how the parts work
together with a target which can do less than nvmet: it implements
list-ports, show-port and set-host, and the tests which need more skip.

Tested with two QEMU VMs, the host running the nvme group against
nvmet (Debian 6.12 kernel) in the target VM over tcp:

- passed: 003-006, 008, 010, 012, 014, 018, 019, 021-023, 025-029, 031,
  040-045, 047, 057 (ANA failover over four ports), 061, 068, 069, 075,
  and 076, 079 and 080 of the isolcpus series
- failed: 070, the regression test for Identify CNS 07h; the 6.12
  target fails the command
- not run: tests for loop or fc only, tests which need the kernel
  target, local nvmet features (attr_qid_max, reservations), tlshd or
  ULP_DDP

nvme/029, nvme/038, nvme/051, nvme/058, nvme/060 and nvme/065 still pass
with the local target.

With the ontap target of patch 10, NVMe/TCP over the four LIFs of an
SVM and the host on SLES 15 SP7, 003-006, 008, 010, 012, 014, 018, 019,
021-023, 025-028, 040, 041, 044, 045 and 047 pass, and nvme/029, nvme/031,
nvme/057, nvme/062 and nvme/063 skip on the missing features. nvme/042
and nvme/043 fail only on SHA-384, which ONTAP doesn't support.

Unit tests, ruff, mypy and shellcheck are clean.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
Daniel Wagner (10):
      common/nvme: pass --ctrlkey to the target control script
      common/nvme: wait for the namespace of a remote target
      common/nvme: check the subsystem in _nvmet_target_add_ports
      nvme: skip the tests which need the kernel target with a remote target
      nvme/029: skip if the I/O exceeds the maximum transfer size
      contrib: make nvme-target-control a standalone project
      common/nvme: support the ports of a remote target
      nvme/rc: change the host keys on a remote target
      nvme: skip the tests which need a feature the remote target lacks
      contrib/nvme-target-control: add a NetApp ONTAP target

 Documentation/running-tests.md                     |  48 +++
 common/nvme                                        |  83 ++++-
 contrib/nvme-target-control/.gitignore             |   1 +
 contrib/nvme-target-control/README.md              | 177 ++++++++++
 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                  | 222 ++++++++++++
 .../nvme_target_control/targets/__init__.py        |   3 +
 .../nvme_target_control/targets/nvmet.py           | 107 ++++++
 .../nvme_target_control/targets/nvmet.sh           | 246 +++++++++++++
 .../nvme_target_control/targets/ontap.py           | 392 +++++++++++++++++++++
 contrib/nvme-target-control/pyproject.toml         |  42 +++
 contrib/nvme-target-control/tests/test_ontap.py    | 340 ++++++++++++++++++
 .../tests/test_target_control.py                   | 380 ++++++++++++++++++++
 contrib/nvme_target_control.py                     | 204 -----------
 contrib/nvmet-subsys.jinja2                        |  71 ----
 tests/nvme/029                                     |  15 +-
 tests/nvme/031                                     |   1 +
 tests/nvme/038                                     |   1 +
 tests/nvme/042                                     |   1 +
 tests/nvme/043                                     |   1 +
 tests/nvme/044                                     |   1 +
 tests/nvme/045                                     |   1 +
 tests/nvme/051                                     |   1 +
 tests/nvme/057                                     |   1 +
 tests/nvme/058                                     |   1 +
 tests/nvme/060                                     |   1 +
 tests/nvme/062                                     |   1 +
 tests/nvme/063                                     |   1 +
 tests/nvme/065                                     |   1 +
 tests/nvme/rc                                      |  38 ++
 34 files changed, 2466 insertions(+), 291 deletions(-)
---
base-commit: a65cf6bdca19cf98e80ba379bffbf3a67f86fba3
change-id: 20261104-nvme-target-control-f0b239e68b29

Best regards,
--  
Daniel Wagner <dwagner@suse.de>


^ permalink raw reply	[flat|nested] 16+ messages in thread

* [PATCH blktests 01/10] common/nvme: pass --ctrlkey to the target control script
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 02/10] common/nvme: wait for the namespace of a remote target Daniel Wagner
                   ` (9 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

_nvmet_target_setup and _nvmet_target_add_ports pass the controller key
to the NVME_TARGET_CONTROL script as --ctrkey. running-tests.md and
contrib/nvme_target_control.py name the option --ctrlkey. The script
rejects --ctrkey, so the setup fails for the tests with a controller
key, nvme/044 and nvme/045.

Pass --ctrlkey.

Fixes: 5b6d89ced118 ("nvme/rc: introduce remote target support")
Fixes: db623ee8039f ("nvme: Add _nvmet_target_add_ports")
Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 common/nvme | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/common/nvme b/common/nvme
index ad3d140a8811..a8c7634ec2b8 100644
--- a/common/nvme
+++ b/common/nvme
@@ -1611,7 +1611,7 @@ _nvmet_target_setup() {
 			ARGS+=(--hostkey "${hostkey}")
 		fi
 		if [[ -n "${ctrlkey}" ]]; then
-			ARGS+=(--ctrkey "${ctrlkey}")
+			ARGS+=(--ctrlkey "${ctrlkey}")
 		fi
 
 		"${nvme_target_control}" setup \
@@ -1708,7 +1708,7 @@ _nvmet_target_add_ports() {
 			ARGS+=(--hostkey "${hostkey}")
 		fi
 		if [[ -n "${ctrlkey}" ]]; then
-			ARGS+=(--ctrkey "${ctrlkey}")
+			ARGS+=(--ctrlkey "${ctrlkey}")
 		fi
 
 		"${nvme_target_control}" add-ports \

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 02/10] common/nvme: wait for the namespace of a remote target
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 01/10] common/nvme: pass --ctrlkey to the target control script Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 03/10] common/nvme: check the subsystem in _nvmet_target_add_ports Daniel Wagner
                   ` (8 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

_nvme_ns_ready waits for the namespaces in the local nvmet configfs. With
NVME_TARGET_CONTROL the subsystem is configured on the remote target, so
the configfs directory doesn't exist. The glob stays unexpanded and
_nvme_ns_ready never succeeds. _nvme_connect_subsys then returns 2 after
every connect, and tests which check its return value skip their I/O and
the disconnect.

Wait for the first namespace of the controller instead.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 common/nvme | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/common/nvme b/common/nvme
index a8c7634ec2b8..93782d07dc78 100644
--- a/common/nvme
+++ b/common/nvme
@@ -1027,6 +1027,13 @@ _nvme_ns_ready() {
 	local cfs_path="${NVMET_CFS}/subsystems/$subsysnqn"
 
 	dev=$(_find_nvme_dev "$subsysnqn")
+	if [[ -n "${nvme_target_control}" ]]; then
+		# The namespaces are configured on the remote target, wait
+		# for the first one the controller reports.
+		[[ -b /dev/${dev}n1 && -e /sys/block/${dev}n1/uuid &&
+			-e /sys/block/${dev}n1/wwid ]]
+		return
+	fi
 	for ns_path in "${cfs_path}/namespaces/"*; do
 		ns_id=${ns_path##*/}
 		if [[ ! -b /dev/${dev}n${ns_id} ||

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 03/10] common/nvme: check the subsystem in _nvmet_target_add_ports
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 01/10] common/nvme: pass --ctrlkey to the target control script Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 02/10] common/nvme: wait for the namespace of a remote target Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 04/10] nvme: skip the tests which need the kernel target with a remote target Daniel Wagner
                   ` (7 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

_nvmet_target_add_ports checks that the subsystem exists before it adds
ports to it. It checks the configfs directory of ${subsystem}, which is
not set. The path is then the subsystems directory, which always exists,
so the check never fails.

It also appends the keys to ARGS, which it doesn't declare local. They
end up in the ARGS of the caller.

Check the directory of ${subsysnqn} and declare ARGS local, as
_nvmet_target_setup does.

Fixes: db623ee8039f ("nvme: Add _nvmet_target_add_ports")
Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 common/nvme | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/common/nvme b/common/nvme
index 93782d07dc78..dfe85b6e281f 100644
--- a/common/nvme
+++ b/common/nvme
@@ -1668,6 +1668,7 @@ _nvmet_target_add_ports() {
 	local host_port=0
 	local tls="none"
 	local cfs_path
+	local -a ARGS
 
 	while [[ $# -gt 0 ]]; do
 		case $1 in
@@ -1734,7 +1735,7 @@ _nvmet_target_add_ports() {
 		return
 	fi
 
-	cfs_path="${NVMET_CFS}/subsystems/${subsystem}"
+	cfs_path="${NVMET_CFS}/subsystems/${subsysnqn}"
 	if [[ ! -d "${cfs_path}" ]]; then
 		echo "Can not add ports to a non-existent subsystem. subsystem not found"
 		return 1;

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 04/10] nvme: skip the tests which need the kernel target with a remote target
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (2 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 03/10] common/nvme: check the subsystem in _nvmet_target_add_ports Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 05/10] nvme/029: skip if the I/O exceeds the maximum transfer size Daniel Wagner
                   ` (6 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

nvme/038, nvme/051, nvme/058, nvme/060 and nvme/065 work on the local
nvmet target directly. nvme/038 creates and removes a subsystem in the
local configfs. nvme/051 enables and disables the namespace in the local
configfs. nvme/058 creates its namespaces there. nvme/060 reads the
nvmet debugfs. nvme/065 exports a local scsi_debug device. With
NVME_TARGET_CONTROL the subsystem is on the remote target and the tests
fail.

Skip them with _require_kernel_nvme_target, as nvme/030 and
nvme/072-074 do.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 tests/nvme/038 | 1 +
 tests/nvme/051 | 1 +
 tests/nvme/058 | 1 +
 tests/nvme/060 | 1 +
 tests/nvme/065 | 1 +
 5 files changed, 5 insertions(+)

diff --git a/tests/nvme/038 b/tests/nvme/038
index 4271ee3af951..a5dcdeda14aa 100755
--- a/tests/nvme/038
+++ b/tests/nvme/038
@@ -16,6 +16,7 @@ QUICK=1
 
 requires() {
 	_nvme_requires
+	_require_kernel_nvme_target
 }
 
 set_conditions() {
diff --git a/tests/nvme/051 b/tests/nvme/051
index 8b3bb2b592a5..c5a37af31515 100755
--- a/tests/nvme/051
+++ b/tests/nvme/051
@@ -13,6 +13,7 @@ QUICK=1
 requires() {
 	_nvme_requires
 	_require_nvme_trtype_is_fabrics
+	_require_kernel_nvme_target
 }
 
 set_conditions() {
diff --git a/tests/nvme/058 b/tests/nvme/058
index 997ff7f87be3..a410cf728c52 100755
--- a/tests/nvme/058
+++ b/tests/nvme/058
@@ -12,6 +12,7 @@ requires() {
 	_nvme_requires
 	_have_loop
 	_require_nvme_trtype_is_fabrics
+	_require_kernel_nvme_target
 }
 
 set_conditions() {
diff --git a/tests/nvme/060 b/tests/nvme/060
index 49786afba8cb..4c5fa8c31648 100755
--- a/tests/nvme/060
+++ b/tests/nvme/060
@@ -13,6 +13,7 @@ requires() {
 	_have_loop
 	_require_nvme_trtype tcp rdma fc
 	_have_kernel_option NVME_TARGET_DEBUGFS
+	_require_kernel_nvme_target
 }
 
 set_conditions() {
diff --git a/tests/nvme/065 b/tests/nvme/065
index e878001efbdd..05480f3072fd 100755
--- a/tests/nvme/065
+++ b/tests/nvme/065
@@ -17,6 +17,7 @@ requires() {
 	_have_scsi_debug
 	_nvme_requires
 	_require_nvme_trtype_is_fabrics
+	_require_kernel_nvme_target
 }
 
 set_conditions() {

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 05/10] nvme/029: skip if the I/O exceeds the maximum transfer size
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (3 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 04/10] nvme: skip the tests which need the kernel target with a remote target Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 06/10] contrib: make nvme-target-control a standalone project Daniel Wagner
                   ` (5 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

nvme/029 reads and writes up to 1025 blocks with one passthrough
command. The test was written for 512 byte blocks, where this is 512
KiB. A namespace with 4 KiB blocks needs 4 MiB per command. If the
controller's maximum transfer size is smaller, the kernel rejects the
command with EINVAL and the test fails, e.g. against a target with a
1 MiB MDTS.

Skip the test if 1025 blocks don't fit the maximum transfer size of the
queue.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 tests/nvme/029 | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)

diff --git a/tests/nvme/029 b/tests/nvme/029
index 59f87144e95a..a757cb9915b0 100755
--- a/tests/nvme/029
+++ b/tests/nvme/029
@@ -63,6 +63,19 @@ test() {
 
 	_nvme_connect_subsys
 
+	local ns dev max_bytes
+	ns="$(_find_nvme_ns "${def_subsys_uuid}")"
+	dev="/dev/${ns}"
+
+	# The largest I/O is 1025 blocks, it must fit the maximum transfer size.
+	max_bytes=$(($(cat "/sys/block/${ns}/queue/max_hw_sectors_kb") * 1024))
+	if ((1025 * $(blockdev --getss "$dev") > max_bytes)); then
+		SKIP_REASONS+=("max transfer size ${max_bytes} is smaller than 1025 blocks")
+		_nvme_disconnect_subsys >> "$FULL" 2>&1
+		_nvmet_target_cleanup
+		return 1
+	fi
+
 	# nvme-cli may fail to allocate linear memory for rather large IO buffers.
 	# Increase nr_hugepages to allow nvme-cli to try the linear memory allocation
 	# from HugeTLB pool.
@@ -72,8 +85,6 @@ test() {
 		reset_nr_hugepages=true
 	fi
 
-	local dev
-	dev="/dev/$(_find_nvme_ns "${def_subsys_uuid}")"
 	test_user_io "$dev" 1 512 > "$FULL" 2>&1 || echo FAIL
 	test_user_io "$dev" 1 511 >> "$FULL" 2>&1 || echo FAIL
 	test_user_io "$dev" 1 513 >> "$FULL" 2>&1 || echo FAIL

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 06/10] contrib: make nvme-target-control a standalone project
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (4 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 05/10] nvme/029: skip if the I/O exceeds the maximum transfer size Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 07/10] common/nvme: support the ports of a remote target Daniel Wagner
                   ` (4 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

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@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@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'
+
+
+@dataclass(frozen=True)
+class Subsystem:
+    """The subsystem blktests wants to connect to."""
+
+    nqn: str
+    uuid: str
+    hostnqn: str
+    hostkey: str = ''
+    ctrlkey: str = ''
+
+
+@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@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@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@t', 'ssh_options': ['-p', '22']}
+        )
+        cmd = ch.command(['nqn with space', "it's", ''])
+        self.assertEqual(cmd[:4], ['ssh', '-p', '22', 'root@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@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 07/10] common/nvme: support the ports of a remote target
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (5 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 06/10] contrib: make nvme-target-control a standalone project Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-11 10:30   ` Shin'ichiro Kawasaki
  2026-11-04 16:44 ` [PATCH blktests 08/10] nvme/rc: change the host keys on " Daniel Wagner
                   ` (3 subsequent siblings)
  10 siblings, 1 reply; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

With NVME_TARGET_CONTROL the port helpers still use the local nvmet
configfs. _create_nvmet_port, _remove_nvmet_port and
_setup_nvmet_port_ana write to it. _get_nvmet_ports and
_get_nvmet_port_params read from it. The ports of a remote subsystem
are not there. _nvmet_target_setup doesn't pass the number of ports to
the script, and _nvmet_target_add_ports only reports that add-ports is a
stub. The tests which use more than one port don't work with a remote
target, e.g. nvme/057 finds no port and connects to none.

Use the port commands of the script instead: setup --ports, add-ports,
add-port, remove-port, list-ports, show-port and set-ana, with --tls or
--force-tls for the TLS mode. _nvme_connect_subsys finds the port of the
subsystem with list-ports, as it does with the local target. If the
script lists no port, e.g. for a target which is configured already, it
connects to the address config shows, as before.

Document the port commands in running-tests.md.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 Documentation/running-tests.md | 29 ++++++++++++++++++
 common/nvme                    | 69 +++++++++++++++++++++++++++++++++++-------
 2 files changed, 87 insertions(+), 11 deletions(-)

diff --git a/Documentation/running-tests.md b/Documentation/running-tests.md
index 095d6aa4e18e..9ca01e6893ec 100644
--- a/Documentation/running-tests.md
+++ b/Documentation/running-tests.md
@@ -255,10 +255,39 @@ The blktests expects that the script interface implements following
   --hostnqn HOSTNQN
   --ctrlkey CTRLKEY
   --hostkey HOSTKEY
+  --ports NR_PORTS
+  --tls | --force-tls
 
 cleanup:
   --subsysnqn SUBSYSNQN
 
+The tests which use more than one port, or add and remove ports, also need
+the port commands. A port is named by an ID the script picks:
+
+add-ports: export the subsystem on NR_PORTS more ports, print their IDs
+  --subsysnqn SUBSYSNQN
+  --ports NR_PORTS
+  --tls | --force-tls
+
+add-port: create a port which exports no subsystem, print its ID
+  --tls | --force-tls
+
+remove-port:
+  --port PORT
+
+list-ports: print the IDs of the ports which export the subsystem
+  --subsysnqn SUBSYSNQN
+
+show-port: print the transport type, address and service ID of the port
+  --port PORT
+
+set-ana: set the state of an ANA group on the port
+  --port PORT
+  --grpid GRPID
+  --state STATE
+
+If list-ports prints nothing, blktests connects to the address config shows.
+
 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/common/nvme b/common/nvme
index dfe85b6e281f..698d6bf780ef 100644
--- a/common/nvme
+++ b/common/nvme
@@ -888,20 +888,21 @@ _nvme_connect_subsys() {
 		esac
 	done
 
+	if [[ -z "${port}" ]]; then
+		local ports
+
+		_get_nvmet_ports "${subsysnqn}" ports
+		port="${ports[0]##*/}"
+	fi
 	if [[ -n "${nvme_target_control}" && -z "${port}" ]]; then
+		# The target doesn't list ports, e.g. it is configured already.
 		ARGS+=(--transport "$(${nvme_target_control} config --show-trtype)")
 		ARGS+=(--traddr "${def_traddr}")
 		ARGS+=(--trsvcid "${def_trsvcid}")
 	else
 		if [[ -z "${port}" ]]; then
-			local ports
-
-			_get_nvmet_ports "${subsysnqn}" ports
-			port="${ports[0]##*/}"
-			if [[ -z "${port}" ]]; then
-				echo "WARNING: no port found"
-				return 1
-			fi
+			echo "WARNING: no port found"
+			return 1
 		fi
 		_get_nvmet_port_params "${port}" ARGS
 	fi
@@ -1079,6 +1080,18 @@ _fc_host_traddr() {
 	printf "nn-%s:pn-%s" "$(_host_wwnn $port)" "$(_host_wwpn $port)"
 }
 
+# Print the nvme-target-control option for the TLS mode of a port.
+_nvme_target_control_tls() {
+	case "$1" in
+	not-required)
+		echo "--tls"
+		;;
+	required)
+		echo "--force-tls"
+		;;
+	esac
+}
+
 _create_nvmet_port() {
 	local tls="${1:-none}"
 	local host_port="${2:-0}"
@@ -1088,6 +1101,13 @@ _create_nvmet_port() {
 	local trsvcid="${def_trsvcid}"
 	local portcfs
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		# shellcheck disable=SC2046
+		"${nvme_target_control}" add-port \
+			$(_nvme_target_control_tls "${tls}")
+		return
+	fi
+
 	local port
 	for ((port = 0; ; port++)); do
 		if [[ ! -e "${NVMET_CFS}/ports/${port}" ]]; then
@@ -1157,6 +1177,12 @@ _setup_nvmet_port_ana() {
 	local cfsport="${NVMET_CFS}/ports/${port}"
 	local anaport="${cfsport}/ana_groups/${anagrpid}"
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		"${nvme_target_control}" set-ana --port "${port}" \
+			--grpid "${anagrpid}" --state "${anastate}"
+		return
+	fi
+
 	if [[ ! -d "${anaport}" ]] ; then
 		if [[ "${anagrpid}" -eq 1 ]]; then
 			echo "FAIL target setup failed, ANA not supported"
@@ -1173,6 +1199,11 @@ _remove_nvmet_port() {
 	local cfsport="${NVMET_CFS}/ports/${port}"
 	local a
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		"${nvme_target_control}" remove-port --port "${port}"
+		return
+	fi
+
 	if [[ "${nvme_trtype}" == "fc" ]]; then
 		_nvme_fcloop_del_tport "$(_remote_wwnn "$port")" \
 				       "$(_remote_wwpn "$port")"
@@ -1446,6 +1477,13 @@ _get_nvmet_ports() {
 	local -n nvmet_ports="$2"
 	local cfs_path="${NVMET_CFS}/ports"
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		mapfile -t -O "${#nvmet_ports[@]}" nvmet_ports < <(
+			"${nvme_target_control}" list-ports \
+				--subsysnqn "${nvmet_subsysnqn}")
+		return
+	fi
+
 	for port in "${cfs_path}/"*; do
 		if [[ -e "${port}/subsystems/${nvmet_subsysnqn}" ]]; then
 			nvmet_ports+=("${port##*/}")
@@ -1473,7 +1511,12 @@ _get_nvmet_port_params() {
 	local trsvcid="${def_trsvcid}"
 
 	# When port is specified, get port dependent parameter values
-	if [[ $port != none ]]; then
+	if [[ $port != none && -n "${nvme_target_control}" ]]; then
+		read -r trtype traddr trsvcid < <(
+			"${nvme_target_control}" show-port --port "${port}")
+		[[ -n "${trsvcid}" ]] || exit 1
+		args+=(--traddr "${traddr}")
+	elif [[ $port != none ]]; then
 		[[ -d "${cfs_path}" ]] || exit 1
 		trtype=$(cat "${cfs_path}/addr_trtype")
 		traddr=$(cat "${cfs_path}/addr_traddr")
@@ -1621,10 +1664,13 @@ _nvmet_target_setup() {
 			ARGS+=(--ctrlkey "${ctrlkey}")
 		fi
 
+		# shellcheck disable=SC2046
 		"${nvme_target_control}" setup \
 					 --subsysnqn "${subsysnqn}" \
 					 --subsys-uuid "${subsys_uuid:-$def_subsys_uuid}" \
 					 --hostnqn "${def_hostnqn}" \
+					 --ports "${num_ports}" \
+					 $(_nvme_target_control_tls "${tls}") \
 					 "${ARGS[@]}" &> /dev/null
 		p=0
 		while (( p < num_ports )); do
@@ -1719,19 +1765,20 @@ _nvmet_target_add_ports() {
 			ARGS+=(--ctrlkey "${ctrlkey}")
 		fi
 
+		# shellcheck disable=SC2046
 		"${nvme_target_control}" add-ports \
 					 --ports "${num_ports}" \
 					 --subsysnqn "${subsysnqn}" \
 					 --subsys-uuid "${subsys_uuid:-$def_subsys_uuid}" \
 					 --hostnqn "${def_hostnqn}" \
-					 "${ARGS[@]}" &> /dev/null
+					 $(_nvme_target_control_tls "${tls}") \
+					 "${ARGS[@]}" > /dev/null
 		p=0
 		while (( p < num_ports )); do
 			# Set ports_to_hosts so that _get_fc_host_port() can return the correct host port
 			ports_to_hosts+=("${host_port}")
 			p=$(( p + 1 ))
 		done
-		echo "'${nvme_target_control} add-ports' is a stub"
 		return
 	fi
 

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 08/10] nvme/rc: change the host keys on a remote target
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (6 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 07/10] common/nvme: support the ports of a remote target Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-04 16:44 ` [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks Daniel Wagner
                   ` (2 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

The authentication tests change the DH-CHAP keys, hash and DH group of
the host with _set_nvmet_hostkey, _set_nvmet_ctrlkey, _set_nvmet_hash
and _set_nvmet_dhgroup. They write to the local nvmet configfs. With
NVME_TARGET_CONTROL the host is configured on the remote target, the
writes fail and the target keeps the old values. nvme/042 to nvme/045
fail on the write errors. nvme/045 renews the keys and changes the DH
group, and the re-authentication with the new values fails.

Change the values on the target with the set-host command of the
script. Document the command in running-tests.md.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 Documentation/running-tests.md |  9 +++++++++
 tests/nvme/rc                  | 20 ++++++++++++++++++++
 2 files changed, 29 insertions(+)

diff --git a/Documentation/running-tests.md b/Documentation/running-tests.md
index 9ca01e6893ec..847792cec240 100644
--- a/Documentation/running-tests.md
+++ b/Documentation/running-tests.md
@@ -288,6 +288,15 @@ set-ana: set the state of an ANA group on the port
 
 If list-ports prints nothing, blktests connects to the address config shows.
 
+The authentication tests change the DH-CHAP attributes of the host:
+
+set-host:
+  --hostnqn HOSTNQN
+  --hostkey HOSTKEY
+  --ctrlkey CTRLKEY
+  --hash HASH
+  --dhgroup DHGROUP
+
 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/tests/nvme/rc b/tests/nvme/rc
index 8abd14eeb112..3c3ed680e416 100644
--- a/tests/nvme/rc
+++ b/tests/nvme/rc
@@ -247,6 +247,11 @@ _set_nvmet_hostkey() {
 	local nvmet_hostkey="$2"
 	local cfs_path="${NVMET_CFS}/hosts/${nvmet_hostnqn}"
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		"${nvme_target_control}" set-host --hostnqn "${nvmet_hostnqn}" \
+			--hostkey "${nvmet_hostkey}"
+		return
+	fi
 	_set_attr "${nvmet_hostkey}" "${cfs_path}/dhchap_key"
 }
 
@@ -255,6 +260,11 @@ _set_nvmet_ctrlkey() {
 	local nvmet_ctrlkey="$2"
 	local cfs_path="${NVMET_CFS}/hosts/${nvmet_hostnqn}"
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		"${nvme_target_control}" set-host --hostnqn "${nvmet_hostnqn}" \
+			--ctrlkey "${nvmet_ctrlkey}"
+		return
+	fi
 	_set_attr "${nvmet_ctrlkey}" "${cfs_path}/dhchap_ctrl_key"
 }
 
@@ -263,6 +273,11 @@ _set_nvmet_hash() {
 	local nvmet_hash="$2"
 	local cfs_path="${NVMET_CFS}/hosts/${nvmet_hostnqn}"
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		"${nvme_target_control}" set-host --hostnqn "${nvmet_hostnqn}" \
+			--hash "${nvmet_hash}"
+		return
+	fi
 	_set_attr "${nvmet_hash}" "${cfs_path}/dhchap_hash"
 }
 
@@ -271,6 +286,11 @@ _set_nvmet_dhgroup() {
 	local nvmet_dhgroup="$2"
 	local cfs_path="${NVMET_CFS}/hosts/${nvmet_hostnqn}"
 
+	if [[ -n "${nvme_target_control}" ]]; then
+		"${nvme_target_control}" set-host --hostnqn "${nvmet_hostnqn}" \
+			--dhgroup "${nvmet_dhgroup}"
+		return
+	fi
 	_set_attr "${nvmet_dhgroup}" "${cfs_path}/dhchap_dhgroup"
 }
 

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (7 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 08/10] nvme/rc: change the host keys on " Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-11 10:33   ` Shin'ichiro Kawasaki
  2026-11-04 16:44 ` [PATCH blktests 10/10] contrib/nvme-target-control: add a NetApp ONTAP target Daniel Wagner
  2026-11-11 10:36 ` [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Shin'ichiro Kawasaki
  10 siblings, 1 reply; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

An NVME_TARGET_CONTROL target need not implement the optional commands.
A storage array, for example, has fixed ports, manages the ANA states
itself and may not take host keys or a TLS key from blktests. The tests
which use a missing command then fail, e.g. nvme/057 on set-ana.

Add _require_nvme_target_control_feature, which checks the features
config --show-features lists. Use it in the tests which add and remove
ports (nvme/031), set ANA states (nvme/057), change host keys (nvme/042
to nvme/045, nvme/063) or set up TLS (nvme/062, nvme/063). No target
lists tls yet: blktests doesn't pass the TLS key to the script.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 Documentation/running-tests.md |  5 +++++
 tests/nvme/031                 |  1 +
 tests/nvme/042                 |  1 +
 tests/nvme/043                 |  1 +
 tests/nvme/044                 |  1 +
 tests/nvme/045                 |  1 +
 tests/nvme/057                 |  1 +
 tests/nvme/062                 |  1 +
 tests/nvme/063                 |  1 +
 tests/nvme/rc                  | 18 ++++++++++++++++++
 10 files changed, 31 insertions(+)

diff --git a/Documentation/running-tests.md b/Documentation/running-tests.md
index 847792cec240..9ac55f2c6724 100644
--- a/Documentation/running-tests.md
+++ b/Documentation/running-tests.md
@@ -248,6 +248,7 @@ The blktests expects that the script interface implements following
   --show-trsvid
   --show-subsys-uuid
   --show-subsysnqn
+  --show-features
 
 setup:
   --subsysnqn SUBSYSNQN
@@ -288,6 +289,10 @@ set-ana: set the state of an ANA group on the port
 
 If list-ports prints nothing, blktests connects to the address config shows.
 
+config --show-features prints the optional commands the script supports,
+add-ports to set-host, and tls if the target can set up TLS. blktests skips
+the tests which need a feature the list lacks.
+
 The authentication tests change the DH-CHAP attributes of the host:
 
 set-host:
diff --git a/tests/nvme/031 b/tests/nvme/031
index cd69e8ef13ac..b12e24489740 100755
--- a/tests/nvme/031
+++ b/tests/nvme/031
@@ -21,6 +21,7 @@ requires() {
 	_nvme_requires
 	_have_loop
 	_require_nvme_trtype_is_fabrics
+	_require_nvme_target_control_feature add-port remove-port
 }
 
 set_conditions() {
diff --git a/tests/nvme/042 b/tests/nvme/042
index bddbd1bbfc28..7ddc0e91706e 100755
--- a/tests/nvme/042
+++ b/tests/nvme/042
@@ -16,6 +16,7 @@ requires() {
 	_require_kernel_nvme_fabrics_feature dhchap_ctrl_secret
 	_require_nvme_trtype_is_fabrics
 	_require_nvme_cli_auth
+	_require_nvme_target_control_feature set-host
 }
 
 set_conditions() {
diff --git a/tests/nvme/043 b/tests/nvme/043
index ac25610a402b..f3b8d640891a 100755
--- a/tests/nvme/043
+++ b/tests/nvme/043
@@ -17,6 +17,7 @@ requires() {
 	_require_nvme_trtype_is_fabrics
 	_require_nvme_cli_auth
 	_have_crypto_algorithm dh-generic
+	_require_nvme_target_control_feature set-host
 }
 
 set_conditions() {
diff --git a/tests/nvme/044 b/tests/nvme/044
index 1d9202143cf4..c1a48f1e3d86 100755
--- a/tests/nvme/044
+++ b/tests/nvme/044
@@ -17,6 +17,7 @@ requires() {
 	_require_nvme_trtype_is_fabrics
 	_require_nvme_cli_auth
 	_have_crypto_algorithm dh-generic
+	_require_nvme_target_control_feature set-host
 }
 
 set_conditions() {
diff --git a/tests/nvme/045 b/tests/nvme/045
index 78a1c607e9c6..14f4949e198e 100755
--- a/tests/nvme/045
+++ b/tests/nvme/045
@@ -18,6 +18,7 @@ requires() {
 	_require_nvme_trtype_is_fabrics
 	_require_nvme_cli_auth
 	_have_crypto_algorithm dh-generic
+	_require_nvme_target_control_feature set-host
 }
 
 set_conditions() {
diff --git a/tests/nvme/057 b/tests/nvme/057
index 0a5cfa4df1a8..6c45af8b5ed9 100755
--- a/tests/nvme/057
+++ b/tests/nvme/057
@@ -13,6 +13,7 @@ requires() {
 	_have_loop
 	_have_fio
 	_require_nvme_trtype_is_fabrics
+	_require_nvme_target_control_feature list-ports show-port set-ana
 }
 
 set_conditions() {
diff --git a/tests/nvme/062 b/tests/nvme/062
index eabe2d99bdcc..4402b848ecee 100755
--- a/tests/nvme/062
+++ b/tests/nvme/062
@@ -18,6 +18,7 @@ requires() {
 	_require_nvme_cli_tls
 	_have_libnvme_ver 1 11
 	_have_systemd_tlshd_service
+	_require_nvme_target_control_feature tls
 }
 
 set_conditions() {
diff --git a/tests/nvme/063 b/tests/nvme/063
index feb07bf3a06c..fae1e8941136 100755
--- a/tests/nvme/063
+++ b/tests/nvme/063
@@ -19,6 +19,7 @@ requires() {
 	_require_nvme_trtype tcp
 	_require_nvme_cli_auth
 	_have_systemd_tlshd_service
+	_require_nvme_target_control_feature set-host tls
 }
 
 set_conditions() {
diff --git a/tests/nvme/rc b/tests/nvme/rc
index 3c3ed680e416..6bdd4058586a 100644
--- a/tests/nvme/rc
+++ b/tests/nvme/rc
@@ -155,6 +155,24 @@ _require_remote_nvme_target() {
 	return 0
 }
 
+# Skip the test if the NVME_TARGET_CONTROL target lacks one of the features,
+# the optional commands and e.g. tls, see Documentation/running-tests.md.
+_require_nvme_target_control_feature() {
+	local features feature
+
+	if [[ -z "${nvme_target_control}" ]]; then
+		return 0
+	fi
+	features=" $("${nvme_target_control}" config --show-features) "
+	for feature in "$@"; do
+		if [[ "${features}" != *" ${feature} "* ]]; then
+			SKIP_REASONS+=("NVME_TARGET_CONTROL target does not support ${feature}")
+			return 1
+		fi
+	done
+	return 0
+}
+
 _test_dev_nvme_ctrl() {
 	echo "/dev/char/$(cat "${TEST_DEV_SYSFS}/device/dev")"
 }

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH blktests 10/10] contrib/nvme-target-control: add a NetApp ONTAP target
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (8 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks Daniel Wagner
@ 2026-11-04 16:44 ` Daniel Wagner
  2026-11-11 10:36 ` [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Shin'ichiro Kawasaki
  10 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-04 16:44 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme, Daniel Wagner

A storage array is a target NVME_TARGET_CONTROL is meant for, and it
doesn't do everything the Linux nvmet target does. Add the ontap target,
which configures a NetApp ONTAP storage virtual machine through the
ONTAP REST API.

On first use the target creates a volume with one namespace. Setup
creates an NVMe subsystem, allows the host and maps the namespace. The
ports are the NVMe/TCP LIFs of the SVM. The target implements
list-ports, show-port and set-host, the tests which need other commands
or TLS skip. README.md documents the configuration and the limits.

Signed-off-by: Daniel Wagner <dwagner@suse.de>
---
 Documentation/running-tests.md                     |   3 +-
 contrib/nvme-target-control/README.md              |  48 +++
 .../nvme_target_control/target.py                  |   1 +
 .../nvme_target_control/targets/ontap.py           | 392 +++++++++++++++++++++
 contrib/nvme-target-control/tests/test_ontap.py    | 340 ++++++++++++++++++
 5 files changed, 783 insertions(+), 1 deletion(-)

diff --git a/Documentation/running-tests.md b/Documentation/running-tests.md
index 9ac55f2c6724..15c7672fade5 100644
--- a/Documentation/running-tests.md
+++ b/Documentation/running-tests.md
@@ -303,5 +303,6 @@ the tests which need a feature the list lacks.
   --dhgroup DHGROUP
 
 contrib/nvme-target-control implements this interface. It configures a Linux
-nvmet target over ssh and can be extended for other targets, see
+nvmet target over ssh or a NetApp ONTAP storage virtual machine through its
+REST API, and can be extended for other targets, see
 contrib/nvme-target-control/README.md.
diff --git a/contrib/nvme-target-control/README.md b/contrib/nvme-target-control/README.md
index dc421b00b788..5bd532cd7c2f 100644
--- a/contrib/nvme-target-control/README.md
+++ b/contrib/nvme-target-control/README.md
@@ -49,6 +49,7 @@ nothing, to run against a target which is configured already.
 | Type | Target |
 | --- | --- |
 | `nvmet` (default) | Linux nvmet target, configured through configfs |
+| `ontap` | NetApp ONTAP storage virtual machine, through the REST API |
 | `package.module:Class` | A `Target` subclass outside this package |
 
 ### nvmet
@@ -80,6 +81,53 @@ 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`.
 
+### ontap
+
+Configures a storage virtual machine (SVM) of a NetApp ONTAP cluster through
+the ONTAP REST API. blktests connects over NVMe/TCP to the LIFs of the SVM:
+
+    [target]
+    type = "ontap"
+    host = "192.0.2.100"
+    user = "admin"
+    password_file = "/root/.config/blktests/ontap-password"
+    svm = "svm1"
+    aggregate = "aggr1"
+
+The first time blktests asks for the subsystem UUID, the target creates the
+volume and the namespace `/vol/VOLUME/ns1` unless they exist. The volume
+gets no snapshots: the tests rewrite the namespace, snapshots would fill
+the volume. Setup creates the subsystem, allows the host and maps the
+namespace to it. Cleanup deletes the subsystem. The volume and namespace
+stay for the next run. The target caches the namespace UUID, the SVM UUID
+and the LIFs in `cache_dir`, and the DH-HMAC-CHAP secrets of the hosts,
+which ONTAP doesn't return.
+
+ONTAP derives the subsystem NQN from the subsystem name: `config
+--show-subsysnqn` shows the NQN of the subsystem `blktests`. The tests which
+use other NQNs need the NQN prefix of the SVM. The ports are the NVMe/TCP
+LIFs, numbered 1 to N by LIF name, each subsystem is reachable on all of
+them. The target implements `list-ports`, `show-port` and `set-host`.
+ONTAP can't modify the DH-HMAC-CHAP settings of a host: `set-host` removes
+the host and adds it again, which drops its connections. ONTAP manages the
+ANA states and doesn't support SHA-384. The target doesn't set up TLS.
+
+| Option | Meaning |
+| --- | --- |
+| `host` | cluster management address |
+| `user` | ONTAP user |
+| `password_file` | file with the password of `user` in its first line, mode 0600 |
+| `svm` | the SVM |
+| `aggregate` | aggregate for the volume, needed if it doesn't exist |
+| `volume` | volume of the namespace, default `blktests` |
+| `volume_size` | size of a new volume, default `4G` |
+| `ns_size` | size of a new namespace, default `2G` |
+| `verify_tls` | verify the certificate of `host`, default true |
+| `cache_dir` | default `~/.cache/nvme-target-control` |
+
+The user needs the rights to manage volumes, namespaces and NVMe
+subsystems of the SVM.
+
 ## Adding a target
 
 A target is a subclass of `nvme_target_control.target.Target`. It gets the
diff --git a/contrib/nvme-target-control/nvme_target_control/target.py b/contrib/nvme-target-control/nvme_target_control/target.py
index d648826e0386..8b2df2e6d6ac 100644
--- a/contrib/nvme-target-control/nvme_target_control/target.py
+++ b/contrib/nvme-target-control/nvme_target_control/target.py
@@ -210,6 +210,7 @@ OPTIONAL_COMMANDS = {
 
 TARGETS = {
     'nvmet': 'nvme_target_control.targets.nvmet:NvmetTarget',
+    'ontap': 'nvme_target_control.targets.ontap:OntapTarget',
 }
 
 
diff --git a/contrib/nvme-target-control/nvme_target_control/targets/ontap.py b/contrib/nvme-target-control/nvme_target_control/targets/ontap.py
new file mode 100644
index 000000000000..198303d1360e
--- /dev/null
+++ b/contrib/nvme-target-control/nvme_target_control/targets/ontap.py
@@ -0,0 +1,392 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""NetApp ONTAP storage virtual machine (SVM), through the ONTAP REST API.
+
+blktests connects over NVMe/TCP to the LIFs of the SVM. [target] options:
+
+  host           cluster management address
+  user           ONTAP user
+  password_file  file with the password of user in the first line
+  svm            the SVM
+  aggregate      aggregate for the volume, needed if it doesn't exist
+  volume         volume of the namespace, default 'blktests'
+  volume_size    size of the volume if it is created, default 4G
+  ns_size        size of the namespace if it is created, default 2G
+  verify_tls     verify the certificate of host, default true
+  cache_dir      default ~/.cache/nvme-target-control
+
+The first time blktests asks for the subsystem UUID, the target creates the
+volume, without snapshots, and the namespace /vol/VOLUME/ns1 unless they
+exist, and caches the namespace UUID, the SVM UUID and the LIFs. Setup
+creates an NVMe subsystem, allows the host and maps the namespace to it.
+Cleanup deletes the subsystem. The volume and namespace stay.
+
+ONTAP derives the subsystem NQN from the subsystem name and the SVM.
+'config --show-subsysnqn' shows the NQN of the subsystem 'blktests'. Other
+NQNs only work with the NQN prefix of the SVM.
+
+The ports are the NVMe/TCP LIFs, numbered 1..N by LIF name. Every
+subsystem is reachable on all of them. Ports can't be added or removed and
+ONTAP manages the ANA states. ONTAP can't modify the DH-HMAC-CHAP settings
+of a host: set-host removes the host and adds it again, which drops its
+connections. The target doesn't set up TLS.
+"""
+
+import base64
+import json
+import ssl
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+from ..target import Port, Subsystem, Target
+
+NQN_PREFIX = 'nqn.1992-08.com.netapp:sn.{}:subsystem.'
+DEF_SUBSYSTEM = 'blktests'
+
+# The names blktests uses for the DH-HMAC-CHAP hash and DH group, and the
+# ones ONTAP uses. ONTAP doesn't support SHA-384.
+HASHES = {
+    'hmac(sha256)': 'sha_256',
+    'hmac(sha512)': 'sha_512',
+}
+DHGROUPS = {
+    'null': 'none',
+    'ffdhe2048': '2048_bit',
+    'ffdhe3072': '3072_bit',
+    'ffdhe4096': '4096_bit',
+    'ffdhe6144': '6144_bit',
+    'ffdhe8192': '8192_bit',
+}
+
+
+class OntapError(OSError):
+    """An ONTAP REST request failed, status is its HTTP status."""
+
+    def __init__(self, message: str, status: int = 0) -> None:
+        super().__init__(message)
+        self.status = status
+
+
+class OntapTarget(Target):
+    """NetApp ONTAP storage virtual machine with NVMe/TCP LIFs."""
+
+    def __init__(self, config: dict[str, Any]) -> None:
+        super().__init__(config)
+        self.svm = str(config['svm'])
+        self.volume = str(config.get('volume', 'blktests'))
+        self.ns_path = f'/vol/{self.volume}/ns1'
+        cache = Path(
+            config.get('cache_dir', '~/.cache/nvme-target-control')
+        ).expanduser()
+        self.cache_file = cache / f'ontap-{config["host"]}-{self.svm}.json'
+        # The DH-HMAC-CHAP settings of the hosts, by subsystem name: ONTAP
+        # doesn't return the secrets, set_host needs them.
+        self.hosts_file = self.cache_file.with_suffix('.hosts.json')
+
+    # REST
+
+    def _request(
+        self,
+        method: str,
+        path: str,
+        body: dict[str, Any] | None = None,
+        query: dict[str, str] | None = None,
+    ) -> dict[str, Any]:
+        url = f'https://{self.config["host"]}/api{path}'
+        if query:
+            url += '?' + urllib.parse.urlencode(query)
+        password = (
+            Path(self.config['password_file']).read_text().split('\n')[0]
+        )
+        auth = f'{self.config["user"]}:{password}'.encode()
+        req = urllib.request.Request(
+            url,
+            method=method,
+            data=json.dumps(body).encode() if body is not None else None,
+            headers={
+                'Authorization': 'Basic ' + base64.b64encode(auth).decode(),
+                'Accept': 'application/json',
+                'Content-Type': 'application/json',
+            },
+        )
+        ctx = (
+            None
+            if self.config.get('verify_tls', True)
+            else ssl._create_unverified_context()
+        )
+        try:
+            with urllib.request.urlopen(req, context=ctx, timeout=60) as r:
+                data = r.read()
+                status = r.status
+        except urllib.error.HTTPError as e:
+            with e:
+                text = e.read().decode(errors='replace')
+            raise OntapError(
+                f'{method} {path}: {e.code} {text}', e.code
+            ) from None
+        result: dict[str, Any] = json.loads(data) if data else {}
+        if status == 202 and 'job' in result:
+            self._wait_job(result['job']['uuid'])
+        return result
+
+    def _wait_job(self, uuid: str) -> None:
+        for _ in range(300):
+            job = self._request('GET', f'/cluster/jobs/{uuid}')
+            if job['state'] == 'success':
+                return
+            if job['state'] == 'failure':
+                raise OntapError(f'job {uuid}: {job.get("message")}')
+            time.sleep(1)
+        raise OntapError(f'job {uuid} did not finish')
+
+    def _records(
+        self, path: str, query: dict[str, str]
+    ) -> list[dict[str, Any]]:
+        records: list[dict[str, Any]] = self._request(
+            'GET', path, query={'svm.name': self.svm, **query}
+        ).get('records', [])
+        return records
+
+    # Values blktests asks for many times per test: cached
+
+    def _cache(self) -> dict[str, Any]:
+        try:
+            cache: dict[str, Any] = json.loads(self.cache_file.read_text())
+            return cache
+        except (OSError, ValueError):
+            pass
+        svm = self._request('GET', '/svm/svms', query={'name': self.svm})
+        lifs = self._records(
+            '/network/ip/interfaces',
+            {'services': 'data_nvme_tcp', 'fields': 'name,ip.address'},
+        )
+        cache = {
+            'svm_uuid': svm['records'][0]['uuid'],
+            'lifs': [
+                lif['ip']['address']
+                for lif in sorted(lifs, key=lambda lif: str(lif['name']))
+            ],
+            'ns_uuid': self._namespace_uuid(),
+        }
+        self.cache_file.parent.mkdir(parents=True, exist_ok=True)
+        self.cache_file.write_text(json.dumps(cache))
+        return cache
+
+    def _namespace_uuid(self) -> str:
+        """Return the UUID of the namespace, create it first if needed."""
+        ns = self._records(
+            '/storage/namespaces', {'name': self.ns_path, 'fields': 'uuid'}
+        )
+        if ns:
+            return str(ns[0]['uuid'])
+        if not self._records('/storage/volumes', {'name': self.volume}):
+            self._request(
+                'POST',
+                '/storage/volumes',
+                {
+                    'name': self.volume,
+                    'svm': {'name': self.svm},
+                    'aggregates': [{'name': self.config['aggregate']}],
+                    'size': self.config.get('volume_size', '4G'),
+                    # The tests rewrite the namespace: snapshots of it
+                    # would fill the volume and fail the writes
+                    'snapshot_policy': {'name': 'none'},
+                    'space': {'snapshot': {'reserve_percent': 0}},
+                },
+            )
+        self._request(
+            'POST',
+            '/storage/namespaces',
+            {
+                'svm': {'name': self.svm},
+                'name': self.ns_path,
+                'os_type': 'linux',
+                'space': {'size': self.config.get('ns_size', '2G')},
+            },
+        )
+        return self._namespace_uuid()
+
+    def traddr(self) -> str:
+        return str(self._cache()['lifs'][0])
+
+    def subsysnqn(self) -> str:
+        return self._nqn_prefix() + DEF_SUBSYSTEM
+
+    def subsys_uuid(self) -> str:
+        return str(self._cache()['ns_uuid'])
+
+    def _nqn_prefix(self) -> str:
+        return NQN_PREFIX.format(self._cache()['svm_uuid'].replace('-', ''))
+
+    def _name(self, subsysnqn: str) -> str:
+        """Return the subsystem name ONTAP derives subsysnqn from."""
+        prefix = self._nqn_prefix()
+        if not subsysnqn.startswith(prefix):
+            raise OntapError(
+                f'{subsysnqn}: ONTAP subsystem NQNs start with {prefix}'
+            )
+        return subsysnqn[len(prefix) :]
+
+    # Subsystems
+
+    def _subsystem(self, name: str) -> dict[str, Any] | None:
+        subsys = self._records('/protocols/nvme/subsystems', {'name': name})
+        return subsys[0] if subsys else None
+
+    def _delete_subsystem(self, uuid: str) -> None:
+        self._request(
+            'DELETE',
+            f'/protocols/nvme/subsystems/{uuid}',
+            query={
+                'allow_delete_while_mapped': 'true',
+                'allow_delete_with_hosts': 'true',
+            },
+        )
+
+    def setup(
+        self, subsys: Subsystem, nr_ports: int = 1, tls: str = 'none'
+    ) -> None:
+        if tls != 'none':
+            raise NotImplementedError('OntapTarget does not set up TLS')
+        name = self._name(subsys.nqn)
+        # A subsystem left by an aborted run would keep the namespace
+        stale = self._records(
+            '/protocols/nvme/subsystem-maps',
+            {
+                'namespace.name': self.ns_path,
+                'fields': 'subsystem.name,subsystem.uuid',
+            },
+        )
+        for m in stale:
+            if not m['subsystem']['name'].startswith(DEF_SUBSYSTEM):
+                raise OntapError(
+                    f'{self.ns_path} is mapped to {m["subsystem"]["name"]}'
+                )
+            self._delete_subsystem(m['subsystem']['uuid'])
+
+        chap = {}
+        if subsys.hostkey:
+            chap['host_secret_key'] = subsys.hostkey
+        if subsys.ctrlkey:
+            chap['controller_secret_key'] = subsys.ctrlkey
+        self._request(
+            'POST',
+            '/protocols/nvme/subsystems',
+            {
+                'svm': {'name': self.svm},
+                'name': name,
+                'os_type': 'linux',
+                'hosts': [self._host(subsys.hostnqn, chap)],
+            },
+        )
+        hosts = self._hosts()
+        hosts[name] = {subsys.hostnqn: chap}
+        self._save_hosts(hosts)
+        self._request(
+            'POST',
+            '/protocols/nvme/subsystem-maps',
+            {
+                'svm': {'name': self.svm},
+                'subsystem': {'name': name},
+                'namespace': {'name': self.ns_path},
+            },
+        )
+
+    def cleanup(self, subsysnqn: str) -> None:
+        name = self._name(subsysnqn)
+        subsys = self._subsystem(name)
+        if subsys:
+            self._delete_subsystem(subsys['uuid'])
+        hosts = self._hosts()
+        if hosts.pop(name, None) is not None:
+            self._save_hosts(hosts)
+
+    # Hosts
+
+    @staticmethod
+    def _host(hostnqn: str, chap: dict[str, str]) -> dict[str, Any]:
+        for field in ('host_secret_key', 'controller_secret_key'):
+            # DHHC-1:02: is a secret transformed with SHA-384
+            if chap.get(field, '').startswith('DHHC-1:02:'):
+                raise OntapError('ONTAP does not support SHA-384 secrets')
+        host: dict[str, Any] = {'nqn': hostnqn}
+        if chap:
+            host['dh_hmac_chap'] = chap
+        return host
+
+    def _hosts(self) -> dict[str, dict[str, dict[str, str]]]:
+        try:
+            hosts: dict[str, dict[str, dict[str, str]]] = json.loads(
+                self.hosts_file.read_text()
+            )
+            return hosts
+        except (OSError, ValueError):
+            return {}
+
+    def _save_hosts(self, hosts: dict[str, dict[str, dict[str, str]]]) -> None:
+        self.hosts_file.parent.mkdir(parents=True, exist_ok=True)
+        # The file holds secrets
+        self.hosts_file.touch(mode=0o600)
+        self.hosts_file.write_text(json.dumps(hosts))
+
+    def set_host(self, hostnqn: str, attrs: dict[str, str]) -> None:
+        """Change the DH-HMAC-CHAP settings of the host.
+
+        ONTAP can't modify them: remove the host from the subsystems setup
+        created and add it again. This drops the connections of the host
+        to these subsystems; the host reconnects.
+        """
+        fields = {
+            'hostkey': ('host_secret_key', None),
+            'ctrlkey': ('controller_secret_key', None),
+            'hash': ('hash_function', HASHES),
+            'dhgroup': ('group_size', DHGROUPS),
+        }
+        hosts = self._hosts()
+        for name, subsys_hosts in hosts.items():
+            if hostnqn not in subsys_hosts:
+                continue
+            # Change a copy: the host keeps its settings if one is invalid
+            chap = dict(subsys_hosts[hostnqn])
+            for attr, value in attrs.items():
+                field, names = fields[attr]
+                if not value:
+                    chap.pop(field, None)
+                elif names is None:
+                    chap[field] = value
+                elif value in names:
+                    chap[field] = names[value]
+                else:
+                    raise OntapError(f'ONTAP does not support {attr} {value}')
+            host = self._host(hostnqn, chap)
+            subsys_hosts[hostnqn] = chap
+            subsys = self._subsystem(name)
+            if not subsys:
+                continue
+            path = f'/protocols/nvme/subsystems/{subsys["uuid"]}/hosts'
+            quoted = urllib.parse.quote(hostnqn, safe='')
+            try:
+                self._request('DELETE', f'{path}/{quoted}')
+            except OntapError as e:
+                # Gone if an earlier set-host failed to add it again
+                if e.status != 404:
+                    raise
+            self._request('POST', path, host)
+        self._save_hosts(hosts)
+
+    # Ports: the LIFs
+
+    def list_ports(self, subsysnqn: str) -> list[str]:
+        if not self._subsystem(self._name(subsysnqn)):
+            return []
+        return [str(i) for i in range(1, len(self._cache()['lifs']) + 1)]
+
+    def show_port(self, port: str) -> Port:
+        lifs = self._cache()['lifs']
+        if not port.isdigit() or not 1 <= int(port) <= len(lifs):
+            raise OntapError(f'no port {port}')
+        return Port('tcp', lifs[int(port) - 1], self.value('trsvid'))
diff --git a/contrib/nvme-target-control/tests/test_ontap.py b/contrib/nvme-target-control/tests/test_ontap.py
new file mode 100644
index 000000000000..49f708a05fd1
--- /dev/null
+++ b/contrib/nvme-target-control/tests/test_ontap.py
@@ -0,0 +1,340 @@
+# SPDX-License-Identifier: GPL-3.0+
+# Copyright (c) 2026 SUSE LLC and contributors
+"""Tests for the ONTAP target, against a fake ONTAP REST API."""
+
+import base64
+import io
+import json
+import sys
+import tempfile
+import unittest
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+from pathlib import Path
+from typing import Any
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from nvme_target_control import target
+from nvme_target_control.target import Subsystem
+from nvme_target_control.targets import ontap
+
+SVM_UUID = 'd714f2f5-ab77-11ed-af12-00a0988fb732'
+PREFIX = 'nqn.1992-08.com.netapp:sn.d714f2f5ab7711edaf1200a0988fb732'
+NQN = PREFIX + ':subsystem.blktests'
+HOSTNQN = 'nqn.2014-08.org.nvmexpress:uuid:57c34e91'
+KEY = 'DHHC-1:01:aGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8hISEhTr8=:'
+KEY2 = 'DHHC-1:00:YW5vdGhlciBrZXkgYW5vdGhlciBrZXkgYW5vdGhlciEhIUV8=:'
+
+
+class Response(io.BytesIO):
+    def __init__(self, status: int, body: dict[str, Any] | None) -> None:
+        super().__init__(
+            json.dumps(body).encode() if body is not None else b''
+        )
+        self.status = status
+
+
+def records(r: list[dict[str, Any]]) -> tuple[int, dict[str, Any]]:
+    """A collection GET reply."""
+    return 200, {'records': r, 'num_records': len(r)}
+
+
+class FakeOntap:
+    """The REST resources the ONTAP target uses, in memory."""
+
+    def __init__(self) -> None:
+        self.requests: list[tuple[str, str, dict[str, Any] | None]] = []
+        self.auth: list[str] = []
+        self.contexts: list[Any] = []
+        self.lifs = [
+            {'name': 'lif-b', 'ip': {'address': '192.0.2.2'}},
+            {'name': 'lif-a', 'ip': {'address': '192.0.2.1'}},
+        ]
+        self.volumes: dict[str, dict[str, Any]] = {}
+        self.namespaces: dict[str, str] = {}
+        self.subsystems: dict[str, dict[str, Any]] = {}
+        self.maps: dict[str, str] = {}  # namespace name -> subsystem name
+        self.job_state = 'success'
+
+    def urlopen(
+        self,
+        req: urllib.request.Request,
+        context: Any = None,
+        timeout: int = 0,
+    ) -> Response:
+        url = urllib.parse.urlsplit(req.full_url)
+        query = dict(urllib.parse.parse_qsl(url.query))
+        body = json.loads(req.data) if req.data else None  # type: ignore[arg-type]
+        method = req.get_method()
+        path = urllib.parse.unquote(url.path.removeprefix('/api'))
+        self.requests.append((method, path, body))
+        self.auth.append(req.get_header('Authorization') or '')
+        self.contexts.append(context)
+        status, result = self.handle(method, path, query, body)
+        if status >= 400:
+            raise urllib.error.HTTPError(
+                req.full_url,
+                status,
+                'error',
+                {},  # type: ignore[arg-type]
+                io.BytesIO(json.dumps(result).encode()),
+            )
+        return Response(status, result)
+
+    def handle(
+        self,
+        method: str,
+        path: str,
+        query: dict[str, str],
+        body: dict[str, Any] | None,
+    ) -> tuple[int, dict[str, Any] | None]:
+        if (method, path) == ('GET', '/svm/svms'):
+            return records([{'uuid': SVM_UUID, 'name': query['name']}])
+        if (method, path) == ('GET', '/network/ip/interfaces'):
+            return records(self.lifs)
+        if path == '/storage/volumes':
+            if method == 'POST':
+                assert body is not None
+                self.volumes[body['name']] = body
+                return 202, {'job': {'uuid': 'job-1'}}
+            vols = [{'name': query['name']}] * (query['name'] in self.volumes)
+            return records(vols)
+        if path.startswith('/cluster/jobs/'):
+            return 200, {'state': self.job_state, 'message': 'no space'}
+        if path == '/storage/namespaces':
+            if method == 'POST':
+                assert body is not None
+                self.namespaces[body['name']] = str(uuid.uuid4())
+                return 201, None
+            name = query['name']
+            if name not in self.namespaces:
+                return records([])
+            return records([{'uuid': self.namespaces[name]}])
+        if path == '/protocols/nvme/subsystem-maps':
+            if method == 'POST':
+                assert body is not None
+                self.maps[body['namespace']['name']] = body['subsystem'][
+                    'name'
+                ]
+                return 201, None
+            subsys = self.maps.get(query['namespace.name'])
+            if subsys is None:
+                return records([])
+            uid = self.subsystems[subsys]['uuid']
+            return records([{'subsystem': {'name': subsys, 'uuid': uid}}])
+        if path == '/protocols/nvme/subsystems':
+            if method == 'POST':
+                assert body is not None
+                self.subsystems[body['name']] = {
+                    'uuid': str(uuid.uuid4()),
+                    'hosts': {h['nqn']: h for h in body['hosts']},
+                }
+                return 201, None
+            name = query['name']
+            if name not in self.subsystems:
+                return records([])
+            return records([{'name': name, **self.subsystems[name]}])
+        parts = path.split('/')
+        found = [
+            n for n, s in self.subsystems.items() if s['uuid'] == parts[4]
+        ]
+        if not found:
+            return 404, {'error': {'message': 'no subsystem'}}
+        subsys = found[0]
+        hosts = self.subsystems[subsys]['hosts']
+        if method == 'DELETE' and len(parts) == 5:
+            del self.subsystems[subsys]
+            self.maps = {k: v for k, v in self.maps.items() if v != subsys}
+            return 200, None
+        if method == 'DELETE':
+            if parts[6] not in hosts:
+                return 404, {'error': {'message': 'no host'}}
+            del hosts[parts[6]]
+            return 200, None
+        assert body is not None
+        hosts[body['nqn']] = body
+        return 201, None
+
+
+class OntapTestCase(unittest.TestCase):
+    def setUp(self) -> None:
+        tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(tmp.cleanup)
+        self.tmp = Path(tmp.name)
+        (self.tmp / 'password').write_text('secret\n')
+        self.fake = FakeOntap()
+        patch = mock.patch.object(urllib.request, 'urlopen', self.fake.urlopen)
+        patch.start()
+        self.addCleanup(patch.stop)
+
+    def target(self, **config: Any) -> ontap.OntapTarget:
+        cfg = {
+            'type': 'ontap',
+            'host': '192.0.2.100',
+            'user': 'admin',
+            'password_file': str(self.tmp / 'password'),
+            'svm': 'svm1',
+            'aggregate': 'aggr1',
+            'cache_dir': str(self.tmp / 'cache'),
+            **config,
+        }
+        tgt = target.create(cfg)
+        assert isinstance(tgt, ontap.OntapTarget)
+        return tgt
+
+    def subsys(self, **keys: str) -> Subsystem:
+        tgt = self.target()
+        return Subsystem(NQN, tgt.value('subsys_uuid'), HOSTNQN, **keys)
+
+
+class TestOntapConfig(OntapTestCase):
+    def test_values(self) -> None:
+        tgt = self.target()
+        self.assertEqual(tgt.value('traddr'), '192.0.2.1')
+        self.assertEqual(tgt.value('trsvid'), '4420')
+        self.assertEqual(tgt.value('subsysnqn'), NQN)
+        self.assertEqual(
+            tgt.value('subsys_uuid'), self.fake.namespaces['/vol/blktests/ns1']
+        )
+        self.assertEqual(
+            tgt.features(), ['list-ports', 'show-port', 'set-host']
+        )
+
+    def test_creates_volume_without_snapshots(self) -> None:
+        self.target(volume='bt', ns_size='1G').value('subsys_uuid')
+        vol = self.fake.volumes['bt']
+        self.assertEqual(vol['aggregates'], [{'name': 'aggr1'}])
+        self.assertEqual(vol['snapshot_policy'], {'name': 'none'})
+        self.assertEqual(vol['space'], {'snapshot': {'reserve_percent': 0}})
+        self.assertIn('/vol/bt/ns1', self.fake.namespaces)
+
+    def test_cached(self) -> None:
+        self.target().value('subsys_uuid')
+        nr = len(self.fake.requests)
+        tgt = self.target()
+        for name in ['traddr', 'subsysnqn', 'subsys_uuid']:
+            tgt.value(name)
+        self.assertEqual(len(self.fake.requests), nr)
+
+    def test_request(self) -> None:
+        self.target(verify_tls=False).value('traddr')
+        auth = base64.b64encode(b'admin:secret').decode()
+        self.assertEqual(set(self.fake.auth), {'Basic ' + auth})
+        self.assertTrue(all(c is not None for c in self.fake.contexts))
+        self.fake.contexts.clear()
+        self.target(cache_dir=str(self.tmp / 'c2')).value('traddr')
+        self.assertTrue(all(c is None for c in self.fake.contexts))
+
+    def test_job_failure(self) -> None:
+        self.fake.job_state = 'failure'
+        with self.assertRaisesRegex(ontap.OntapError, 'no space'):
+            self.target().value('subsys_uuid')
+
+
+class TestOntapSubsystems(OntapTestCase):
+    def test_setup_cleanup(self) -> None:
+        tgt = self.target()
+        tgt.setup(self.subsys(hostkey=KEY, ctrlkey=KEY2), 4)
+        subsys = self.fake.subsystems['blktests']
+        self.assertEqual(
+            subsys['hosts'][HOSTNQN]['dh_hmac_chap'],
+            {'host_secret_key': KEY, 'controller_secret_key': KEY2},
+        )
+        self.assertEqual(self.fake.maps, {'/vol/blktests/ns1': 'blktests'})
+        self.assertEqual(tgt.list_ports(NQN), ['1', '2'])
+        self.assertEqual(
+            tgt.show_port('2'), target.Port('tcp', '192.0.2.2', '4420')
+        )
+        self.assertEqual(tgt.hosts_file.stat().st_mode & 0o777, 0o600)
+
+        tgt.cleanup(NQN)
+        self.assertEqual((self.fake.subsystems, self.fake.maps), ({}, {}))
+        self.assertEqual(tgt.list_ports(NQN), [])
+        self.assertEqual(json.loads(tgt.hosts_file.read_text()), {})
+
+    def test_stale_subsystem(self) -> None:
+        tgt = self.target()
+        tgt.setup(self.subsys())
+        tgt.setup(self.subsys())
+        self.assertEqual(list(self.fake.subsystems), ['blktests'])
+        self.fake.maps['/vol/blktests/ns1'] = 'other'
+        self.fake.subsystems['other'] = {'uuid': 'u', 'hosts': {}}
+        with self.assertRaisesRegex(ontap.OntapError, 'mapped to other'):
+            tgt.setup(self.subsys())
+
+    def test_unsupported(self) -> None:
+        tgt = self.target()
+        with self.assertRaisesRegex(ontap.OntapError, 'start with'):
+            tgt.setup(Subsystem('nqn.foo', 'u', HOSTNQN))
+        with self.assertRaises(NotImplementedError):
+            tgt.setup(self.subsys(), tls='required')
+        with self.assertRaisesRegex(ontap.OntapError, 'no port 3'):
+            tgt.show_port('3')
+        with self.assertRaises(NotImplementedError):
+            tgt.set_ana('1', 1, 'optimized')
+
+
+class TestOntapSetHost(OntapTestCase):
+    def chap(self) -> dict[str, str]:
+        hosts = self.fake.subsystems['blktests']['hosts']
+        chap: dict[str, str] = hosts[HOSTNQN]['dh_hmac_chap']
+        return chap
+
+    def test_set_host(self) -> None:
+        tgt = self.target()
+        tgt.setup(self.subsys(hostkey=KEY))
+        tgt.set_host(HOSTNQN, {'hash': 'hmac(sha512)', 'dhgroup': 'ffdhe8192'})
+        self.assertEqual(
+            self.chap(),
+            {
+                'host_secret_key': KEY,
+                'hash_function': 'sha_512',
+                'group_size': '8192_bit',
+            },
+        )
+        # A new target, as for the next command, knows the secrets
+        self.target().set_host(HOSTNQN, {'hostkey': KEY2, 'ctrlkey': KEY})
+        self.assertEqual(self.chap()['host_secret_key'], KEY2)
+        self.assertEqual(self.chap()['controller_secret_key'], KEY)
+        deletes = [r for r in self.fake.requests if r[0] == 'DELETE']
+        self.assertEqual(len(deletes), 2)
+
+    def test_unsupported_hash(self) -> None:
+        tgt = self.target()
+        tgt.setup(self.subsys(hostkey=KEY))
+        with self.assertRaisesRegex(ontap.OntapError, 'hmac.sha384'):
+            tgt.set_host(HOSTNQN, {'hash': 'hmac(sha384)'})
+
+    def test_sha384_secret(self) -> None:
+        tgt = self.target()
+        with self.assertRaisesRegex(ontap.OntapError, 'SHA-384 secrets'):
+            tgt.setup(self.subsys(hostkey='DHHC-1:02:abc:'))
+        tgt.setup(self.subsys(hostkey=KEY))
+        with self.assertRaisesRegex(ontap.OntapError, 'SHA-384 secrets'):
+            tgt.set_host(HOSTNQN, {'ctrlkey': 'DHHC-1:02:abc:'})
+        # The host is still allowed with the old key
+        self.assertEqual(self.chap(), {'host_secret_key': KEY})
+
+    def test_host_already_removed(self) -> None:
+        tgt = self.target()
+        tgt.setup(self.subsys(hostkey=KEY))
+        del self.fake.subsystems['blktests']['hosts'][HOSTNQN]
+        tgt.set_host(HOSTNQN, {'hostkey': KEY2})
+        self.assertEqual(self.chap(), {'host_secret_key': KEY2})
+
+    def test_http_error(self) -> None:
+        tgt = self.target()
+        tgt.setup(self.subsys())
+        self.fake.subsystems['blktests']['uuid'] = 'gone'
+        self.fake.subsystems['x'] = {'uuid': 'other', 'hosts': {}}
+        with self.assertRaises(ontap.OntapError) as e:
+            tgt._delete_subsystem('missing')
+        self.assertEqual(e.exception.status, 404)
+
+
+if __name__ == '__main__':
+    unittest.main()

-- 
2.56.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* Re: [PATCH blktests 07/10] common/nvme: support the ports of a remote target
  2026-11-04 16:44 ` [PATCH blktests 07/10] common/nvme: support the ports of a remote target Daniel Wagner
@ 2026-11-11 10:30   ` Shin'ichiro Kawasaki
  0 siblings, 0 replies; 16+ messages in thread
From: Shin'ichiro Kawasaki @ 2026-11-11 10:30 UTC (permalink / raw)
  To: Daniel Wagner; +Cc: linux-block, linux-nvme

On Nov 04, 2026 / 17:44, Daniel Wagner wrote:
> With NVME_TARGET_CONTROL the port helpers still use the local nvmet
> configfs. _create_nvmet_port, _remove_nvmet_port and
> _setup_nvmet_port_ana write to it. _get_nvmet_ports and
> _get_nvmet_port_params read from it. The ports of a remote subsystem
> are not there. _nvmet_target_setup doesn't pass the number of ports to
> the script, and _nvmet_target_add_ports only reports that add-ports is a
> stub. The tests which use more than one port don't work with a remote
> target, e.g. nvme/057 finds no port and connects to none.
> 
> Use the port commands of the script instead: setup --ports, add-ports,
> add-port, remove-port, list-ports, show-port and set-ana, with --tls or
> --force-tls for the TLS mode. _nvme_connect_subsys finds the port of the
> subsystem with list-ports, as it does with the local target. If the
> script lists no port, e.g. for a target which is configured already, it
> connects to the address config shows, as before.
> 
> Document the port commands in running-tests.md.
> 
> Signed-off-by: Daniel Wagner <dwagner@suse.de>
[...]
> diff --git a/common/nvme b/common/nvme
> index dfe85b6e281f..698d6bf780ef 100644
> --- a/common/nvme
> +++ b/common/nvme
> @@ -888,20 +888,21 @@ _nvme_connect_subsys() {
>  		esac
>  	done
>  
> +	if [[ -z "${port}" ]]; then
> +		local ports
> +
> +		_get_nvmet_ports "${subsysnqn}" ports
> +		port="${ports[0]##*/}"
> +	fi
>  	if [[ -n "${nvme_target_control}" && -z "${port}" ]]; then
> +		# The target doesn't list ports, e.g. it is configured already.
>  		ARGS+=(--transport "$(${nvme_target_control} config --show-trtype)")
>  		ARGS+=(--traddr "${def_traddr}")
>  		ARGS+=(--trsvcid "${def_trsvcid}")
>  	else
>  		if [[ -z "${port}" ]]; then
> -			local ports
> -
> -			_get_nvmet_ports "${subsysnqn}" ports
> -			port="${ports[0]##*/}"
> -			if [[ -z "${port}" ]]; then
> -				echo "WARNING: no port found"
> -				return 1
> -			fi
> +			echo "WARNING: no port found"
> +			return 1
>  		fi
>  		_get_nvmet_port_params "${port}" ARGS
>  	fi
> @@ -1079,6 +1080,18 @@ _fc_host_traddr() {
>  	printf "nn-%s:pn-%s" "$(_host_wwnn $port)" "$(_host_wwpn $port)"
>  }
>  
> +# Print the nvme-target-control option for the TLS mode of a port.
> +_nvme_target_control_tls() {
> +	case "$1" in
> +	not-required)
> +		echo "--tls"
> +		;;
> +	required)
> +		echo "--force-tls"
> +		;;
> +	esac
> +}
> +
>  _create_nvmet_port() {
>  	local tls="${1:-none}"
>  	local host_port="${2:-0}"
> @@ -1088,6 +1101,13 @@ _create_nvmet_port() {
>  	local trsvcid="${def_trsvcid}"
>  	local portcfs
>  
> +	if [[ -n "${nvme_target_control}" ]]; then
> +		# shellcheck disable=SC2046

I think the shellcheck exception above can be by,

> +		"${nvme_target_control}" add-port \
> +			$(_nvme_target_control_tls "${tls}")

adding double quotations in the line above.

			"$(_nvme_target_control_tls "${tls}")"

> +		return
> +	fi
> +
>  	local port
>  	for ((port = 0; ; port++)); do
>  		if [[ ! -e "${NVMET_CFS}/ports/${port}" ]]; then
> @@ -1157,6 +1177,12 @@ _setup_nvmet_port_ana() {
>  	local cfsport="${NVMET_CFS}/ports/${port}"
>  	local anaport="${cfsport}/ana_groups/${anagrpid}"
>  
> +	if [[ -n "${nvme_target_control}" ]]; then
> +		"${nvme_target_control}" set-ana --port "${port}" \
> +			--grpid "${anagrpid}" --state "${anastate}"
> +		return
> +	fi
> +
>  	if [[ ! -d "${anaport}" ]] ; then
>  		if [[ "${anagrpid}" -eq 1 ]]; then
>  			echo "FAIL target setup failed, ANA not supported"
> @@ -1173,6 +1199,11 @@ _remove_nvmet_port() {
>  	local cfsport="${NVMET_CFS}/ports/${port}"
>  	local a
>  
> +	if [[ -n "${nvme_target_control}" ]]; then
> +		"${nvme_target_control}" remove-port --port "${port}"
> +		return
> +	fi
> +
>  	if [[ "${nvme_trtype}" == "fc" ]]; then
>  		_nvme_fcloop_del_tport "$(_remote_wwnn "$port")" \
>  				       "$(_remote_wwpn "$port")"
> @@ -1446,6 +1477,13 @@ _get_nvmet_ports() {
>  	local -n nvmet_ports="$2"
>  	local cfs_path="${NVMET_CFS}/ports"
>  
> +	if [[ -n "${nvme_target_control}" ]]; then
> +		mapfile -t -O "${#nvmet_ports[@]}" nvmet_ports < <(
> +			"${nvme_target_control}" list-ports \
> +				--subsysnqn "${nvmet_subsysnqn}")
> +		return
> +	fi
> +
>  	for port in "${cfs_path}/"*; do
>  		if [[ -e "${port}/subsystems/${nvmet_subsysnqn}" ]]; then
>  			nvmet_ports+=("${port##*/}")
> @@ -1473,7 +1511,12 @@ _get_nvmet_port_params() {
>  	local trsvcid="${def_trsvcid}"
>  
>  	# When port is specified, get port dependent parameter values
> -	if [[ $port != none ]]; then
> +	if [[ $port != none && -n "${nvme_target_control}" ]]; then
> +		read -r trtype traddr trsvcid < <(
> +			"${nvme_target_control}" show-port --port "${port}")
> +		[[ -n "${trsvcid}" ]] || exit 1
> +		args+=(--traddr "${traddr}")
> +	elif [[ $port != none ]]; then
>  		[[ -d "${cfs_path}" ]] || exit 1
>  		trtype=$(cat "${cfs_path}/addr_trtype")
>  		traddr=$(cat "${cfs_path}/addr_traddr")
> @@ -1621,10 +1664,13 @@ _nvmet_target_setup() {
>  			ARGS+=(--ctrlkey "${ctrlkey}")
>  		fi
>  
> +		# shellcheck disable=SC2046

Same here, I suggest to add,

>  		"${nvme_target_control}" setup \
>  					 --subsysnqn "${subsysnqn}" \
>  					 --subsys-uuid "${subsys_uuid:-$def_subsys_uuid}" \
>  					 --hostnqn "${def_hostnqn}" \
> +					 --ports "${num_ports}" \
> +					 $(_nvme_target_control_tls "${tls}") \

double quotations in the line above.

>  					 "${ARGS[@]}" &> /dev/null
>  		p=0
>  		while (( p < num_ports )); do
> @@ -1719,19 +1765,20 @@ _nvmet_target_add_ports() {
>  			ARGS+=(--ctrlkey "${ctrlkey}")
>  		fi
>  
> +		# shellcheck disable=SC2046

Again, same here,

>  		"${nvme_target_control}" add-ports \
>  					 --ports "${num_ports}" \
>  					 --subsysnqn "${subsysnqn}" \
>  					 --subsys-uuid "${subsys_uuid:-$def_subsys_uuid}" \
>  					 --hostnqn "${def_hostnqn}" \
> -					 "${ARGS[@]}" &> /dev/null
> +					 $(_nvme_target_control_tls "${tls}") \

double qutotaions above.

> +					 "${ARGS[@]}" > /dev/null
>  		p=0
>  		while (( p < num_ports )); do
>  			# Set ports_to_hosts so that _get_fc_host_port() can return the correct host port
>  			ports_to_hosts+=("${host_port}")
>  			p=$(( p + 1 ))
>  		done
> -		echo "'${nvme_target_control} add-ports' is a stub"
>  		return
>  	fi
>  
> 
> -- 
> 2.56.0
> 

^ permalink raw reply	[flat|nested] 16+ messages in thread

* Re: [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks
  2026-11-04 16:44 ` [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks Daniel Wagner
@ 2026-11-11 10:33   ` Shin'ichiro Kawasaki
  2026-11-12  9:17     ` Daniel Wagner
  0 siblings, 1 reply; 16+ messages in thread
From: Shin'ichiro Kawasaki @ 2026-11-11 10:33 UTC (permalink / raw)
  To: Daniel Wagner; +Cc: linux-block, linux-nvme

On Nov 04, 2026 / 17:44, Daniel Wagner wrote:
> An NVME_TARGET_CONTROL target need not implement the optional commands.
> A storage array, for example, has fixed ports, manages the ANA states
> itself and may not take host keys or a TLS key from blktests. The tests
> which use a missing command then fail, e.g. nvme/057 on set-ana.
> 
> Add _require_nvme_target_control_feature, which checks the features
> config --show-features lists. Use it in the tests which add and remove
> ports (nvme/031), set ANA states (nvme/057), change host keys (nvme/042
> to nvme/045, nvme/063) or set up TLS (nvme/062, nvme/063). No target
> lists tls yet: blktests doesn't pass the TLS key to the script.
> 
> Signed-off-by: Daniel Wagner <dwagner@suse.de>
[...]
> diff --git a/tests/nvme/rc b/tests/nvme/rc
> index 3c3ed680e416..6bdd4058586a 100644
> --- a/tests/nvme/rc
> +++ b/tests/nvme/rc
> @@ -155,6 +155,24 @@ _require_remote_nvme_target() {
>  	return 0
>  }
>  
> +# Skip the test if the NVME_TARGET_CONTROL target lacks one of the features,
> +# the optional commands and e.g. tls, see Documentation/running-tests.md.
> +_require_nvme_target_control_feature() {
> +	local features feature
> +
> +	if [[ -z "${nvme_target_control}" ]]; then
> +		return 0
> +	fi
> +	features=" $("${nvme_target_control}" config --show-features) "
> +	for feature in "$@"; do
> +		if [[ "${features}" != *" ${feature} "* ]]; then

The line above is a bit confusing for me. I guess the line can be simpler as
follows:

		if [[ "${features}" =~ " ${feature} " ]]; then

May I ask you to confirm it?

> +			SKIP_REASONS+=("NVME_TARGET_CONTROL target does not support ${feature}")
> +			return 1
> +		fi
> +	done
> +	return 0
> +}
> +
>  _test_dev_nvme_ctrl() {
>  	echo "/dev/char/$(cat "${TEST_DEV_SYSFS}/device/dev")"
>  }
> 
> -- 
> 2.56.0
> 

^ permalink raw reply	[flat|nested] 16+ messages in thread

* Re: [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets
  2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
                   ` (9 preceding siblings ...)
  2026-11-04 16:44 ` [PATCH blktests 10/10] contrib/nvme-target-control: add a NetApp ONTAP target Daniel Wagner
@ 2026-11-11 10:36 ` Shin'ichiro Kawasaki
  2026-11-12  9:19   ` Daniel Wagner
  10 siblings, 1 reply; 16+ messages in thread
From: Shin'ichiro Kawasaki @ 2026-11-11 10:36 UTC (permalink / raw)
  To: Daniel Wagner; +Cc: linux-block, linux-nvme

On Nov 04, 2026 / 17:44, Daniel Wagner wrote:
> Running the nvme fabrics tests against a remote target with
> NVME_TARGET_CONTROL doesn't work in current upstream:
> 
> - contrib/nvme_target_control.py configures the target with nvmetcli
>   through a REST API which only exists in an nvmetcli branch that was
>   never merged, and its nvmetcli client crashes with configshell-fb 2.0.
>   The target is never set up and the tests fail to connect.
> 
> - With a target set up by other means, the tests pass without testing
>   anything: _nvme_ns_ready waits for the namespaces in the local nvmet
>   configfs, which doesn't exist for a remote target. _nvme_connect_subsys
>   returns an error after every connect, the tests skip their I/O and the
>   disconnect, and the harness reports leftover controllers.
> 
> - The tests which use more than one port configure the ports in the
>   local nvmet configfs, and add-ports is a stub. The authentication
>   tests change the host keys there too.

(snip)

Daniel, thank you for the series. I walked through the patches and all look
valuable. I applied first six patches. I made minor comments on the two patches
out of the left four patches. Please see if they make sense for you.

^ permalink raw reply	[flat|nested] 16+ messages in thread

* Re: [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks
  2026-11-11 10:33   ` Shin'ichiro Kawasaki
@ 2026-11-12  9:17     ` Daniel Wagner
  0 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-12  9:17 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme

On Wed, Nov 11, 2026 at 07:33:36PM +0900, Shin'ichiro Kawasaki wrote:
> > +	features=" $("${nvme_target_control}" config --show-features) "
> > +	for feature in "$@"; do
> > +		if [[ "${features}" != *" ${feature} "* ]]; then
> 
> The line above is a bit confusing for me. I guess the line can be simpler as
> follows:
> 
> 		if [[ "${features}" =~ " ${feature} " ]]; then
> 
> May I ask you to confirm it?

Agreed, Klaus likes complex stuff, after he is German. An a more serious
note: though to keep the logic consistent it would be

        if [[ ! "${features}" =~ " ${feature} " ]]; then

The original line checks if the feature is missing so that the function
can skip the test.

I'll change it. Thanks!

^ permalink raw reply	[flat|nested] 16+ messages in thread

* Re: [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets
  2026-11-11 10:36 ` [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Shin'ichiro Kawasaki
@ 2026-11-12  9:19   ` Daniel Wagner
  0 siblings, 0 replies; 16+ messages in thread
From: Daniel Wagner @ 2026-11-12  9:19 UTC (permalink / raw)
  To: Shin'ichiro Kawasaki; +Cc: linux-block, linux-nvme

On Wed, Nov 11, 2026 at 07:36:31PM +0900, Shin'ichiro Kawasaki wrote:
> Daniel, thank you for the series. I walked through the patches and all look
> valuable. I applied first six patches. I made minor comments on the two patches
> out of the left four patches. Please see if they make sense for you.

Thanks, yes sure, I'll update the remaining patches.

^ permalink raw reply	[flat|nested] 16+ messages in thread

end of thread, other threads:[~2026-11-12  9:19 UTC | newest]

Thread overview: 16+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-11-04 16:44 [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 01/10] common/nvme: pass --ctrlkey to the target control script Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 02/10] common/nvme: wait for the namespace of a remote target Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 03/10] common/nvme: check the subsystem in _nvmet_target_add_ports Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 04/10] nvme: skip the tests which need the kernel target with a remote target Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 05/10] nvme/029: skip if the I/O exceeds the maximum transfer size Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 06/10] contrib: make nvme-target-control a standalone project Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 07/10] common/nvme: support the ports of a remote target Daniel Wagner
2026-11-11 10:30   ` Shin'ichiro Kawasaki
2026-11-04 16:44 ` [PATCH blktests 08/10] nvme/rc: change the host keys on " Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 09/10] nvme: skip the tests which need a feature the remote target lacks Daniel Wagner
2026-11-11 10:33   ` Shin'ichiro Kawasaki
2026-11-12  9:17     ` Daniel Wagner
2026-11-04 16:44 ` [PATCH blktests 10/10] contrib/nvme-target-control: add a NetApp ONTAP target Daniel Wagner
2026-11-11 10:36 ` [PATCH blktests 00/10] nvme: make NVME_TARGET_CONTROL work with remote targets Shin'ichiro Kawasaki
2026-11-12  9:19   ` Daniel Wagner

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox