[PATCH blktests 10/10] contrib/nvme-target-control: add a NetApp ONTAP target
Daniel Wagner
dwagner at suse.de
Wed Nov 4 08:44:49 PST 2026
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 at 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
More information about the Linux-nvme
mailing list