From: Joaquin Varela <joaquinvarela@neatech.ar>
To: pve-devel@lists.proxmox.com
Cc: Joaquin Varela <joaquinvarela@neatech.ar>
Subject: [PATCH storage v2 7/7] zfsnvme: restore ACLs before publishing target
Date: Sun, 2 Aug 2026 00:31:41 -0300 [thread overview]
Message-ID: <c49747888d51f922f579839a890f9e5096dc35cf.1785636979.git.joaquinvarela@neatech.ar> (raw)
In-Reply-To: <cover.1785636979.git.joaquinvarela@neatech.ar>
Keep the subsystem unreachable while reconstructing derived configfs
state. Restore every configured Host NQN, its DH-HMAC-CHAP key and
namespace before linking the subsystem into any NVMe/TCP port.
This prevents reconnecting initiators from treating a transient host-not-
allowed response as a terminal controller failure after a target reboot.
Signed-off-by: Joaquin Varela <joaquinvarela@neatech.ar>
---
src/PVE/Storage/LunCmd/NVMET.pm | 47 +++++++++++++++++++++-----
src/PVE/Storage/ZFSNVMePlugin.pm | 58 ++++++++++++++++++++++++++++++--
src/test/zfsnvme_test.pm | 40 +++++++++++++++++++++-
3 files changed, 132 insertions(+), 13 deletions(-)
diff --git a/src/PVE/Storage/LunCmd/NVMET.pm b/src/PVE/Storage/LunCmd/NVMET.pm
index 7a902da..c5b9203 100644
--- a/src/PVE/Storage/LunCmd/NVMET.pm
+++ b/src/PVE/Storage/LunCmd/NVMET.pm
@@ -112,8 +112,7 @@ allocate_port_id() {
}
ensure_port() {
- local nqn="$1"
- local spec="$2"
+ local spec="$1"
local family address service id port
IFS=, read -r family address service <<<"$spec"
@@ -137,10 +136,6 @@ ensure_port() {
printf '%s\n' "$service" >"$port/addr_trsvcid"
fi
- port="$ROOT/ports/$id"
- if [[ ! -e "$port/subsystems/$nqn" ]]; then
- ln -s "$ROOT/subsystems/$nqn" "$port/subsystems/$nqn"
- fi
printf '%s\n' "$id"
}
@@ -148,7 +143,7 @@ ensure_target() {
local nqn="$1"
local pool="$2"
shift 2
- local spec id port desired_ports=''
+ local spec
validate_nqn "$nqn"
validate_pool "$pool"
@@ -156,7 +151,31 @@ ensure_target() {
ensure_subsystem "$nqn"
for spec in "$@"; do
- id="$(ensure_port "$nqn" "$spec")"
+ ensure_port "$spec" >/dev/null
+ done
+}
+
+publish_target() {
+ local nqn="$1"
+ shift
+ local spec id port desired_ports=''
+
+ validate_nqn "$nqn"
+ (($# >= 1)) || die "at least one NVMe/TCP portal is required"
+ [[ -d "$ROOT/subsystems/$nqn" ]] || die "NVMe subsystem does not exist"
+
+ # The subsystem only becomes reachable after every namespace, host ACL and
+ # authentication key has been restored. Publishing it earlier makes an
+ # initiator treat a transient "host not allowed" response as permanent and
+ # remove the controller, failing queued I/O despite ctrl_loss_tmo.
+ for spec in "$@"; do
+ IFS=, read -r family address service <<<"$spec"
+ id="$(find_port "$family" "$address" "$service" || true)"
+ [[ -n "$id" ]] || die "NVMe/TCP portal '$spec' is not configured"
+ port="$ROOT/ports/$id"
+ if [[ ! -e "$port/subsystems/$nqn" ]]; then
+ ln -s "$ROOT/subsystems/$nqn" "$port/subsystems/$nqn"
+ fi
desired_ports="$desired_ports $id"
done
@@ -523,6 +542,7 @@ shift || true
case "$mode" in
ensure-target) ensure_target "$@" ;;
+ publish-target) publish_target "$@" ;;
ensure-host) ensure_host "$@" ;;
allow-host) allow_host "$@" ;;
create) create_volume "$@" ;;
@@ -616,10 +636,15 @@ sub get_base($scfg) {
return '/dev/zvol';
}
-sub ensure_target($scfg, $hostnqn, $portals) {
+sub ensure_target($scfg, $portals) {
my $nqn = $scfg->{subsysnqn};
remote_call($scfg, 15, 'ensure-target', $nqn, $scfg->{pool}, $portals->@*);
+}
+
+sub ensure_host($scfg, $hostnqn) {
+ my $nqn = $scfg->{subsysnqn};
+
remote_call($scfg, 10, 'ensure-host', $nqn, $hostnqn);
}
@@ -657,6 +682,10 @@ sub reconcile($scfg) {
remote_call($scfg, 30, 'reconcile', $scfg->{subsysnqn}, $scfg->{pool});
}
+sub publish_target($scfg, $portals) {
+ remote_call($scfg, 15, 'publish-target', $scfg->{subsysnqn}, $portals->@*);
+}
+
sub delete_target($scfg) {
remote_call($scfg, 15, 'delete-target', $scfg->{subsysnqn});
}
diff --git a/src/PVE/Storage/ZFSNVMePlugin.pm b/src/PVE/Storage/ZFSNVMePlugin.pm
index 54c406d..eedc67d 100644
--- a/src/PVE/Storage/ZFSNVMePlugin.pm
+++ b/src/PVE/Storage/ZFSNVMePlugin.pm
@@ -19,6 +19,7 @@ my $nvme = '/usr/sbin/nvme';
my $secret_dir = '/etc/pve/priv/storage';
my $runtime_dir = '/run/pve-storage';
my $max_paths = 16;
+my $max_hosts = 64;
my $RE_NQN = qr{
\A
@@ -140,11 +141,41 @@ sub parse_nvme_host_ifaces($value, $noerr = undef) {
return $result;
}
+sub parse_nvme_host_nqns($value, $noerr = undef) {
+ my $result = [];
+ my $seen = {};
+
+ for my $hostnqn (split(/,/, $value // '')) {
+ $hostnqn = trim($hostnqn);
+ if (!verify_nvme_nqn($hostnqn, 1) || $seen->{$hostnqn}++) {
+ return undef if $noerr;
+ die "invalid or duplicate NVMe host NQN '$hostnqn'\n";
+ }
+ push $result->@*, $hostnqn;
+ if (scalar($result->@*) > $max_hosts) {
+ return undef if $noerr;
+ die "at most $max_hosts NVMe host NQNs are supported\n";
+ }
+ }
+
+ if (!$result->@*) {
+ return undef if $noerr;
+ die "at least one NVMe host NQN is required\n";
+ }
+
+ return $result;
+}
+
my sub verify_nvme_host_ifaces($value, $noerr = undef) {
return undef if !parse_nvme_host_ifaces($value, $noerr);
return $value;
}
+my sub verify_nvme_host_nqns($value, $noerr = undef) {
+ return undef if !parse_nvme_host_nqns($value, $noerr);
+ return $value;
+}
+
sub _configured_portals($scfg) {
my $portals = parse_nvme_portals($scfg->{'nvme-portals'});
my $ifaces = parse_nvme_host_ifaces($scfg->{'nvme-host-ifaces'});
@@ -172,6 +203,7 @@ sub _validate_local_ifaces($portals) {
PVE::JSONSchema::register_format('pve-storage-nvme-nqn', \&verify_nvme_nqn);
PVE::JSONSchema::register_format('pve-storage-nvme-portals', \&verify_nvme_portals);
PVE::JSONSchema::register_format('pve-storage-nvme-host-ifaces', \&verify_nvme_host_ifaces);
+PVE::JSONSchema::register_format('pve-storage-nvme-host-nqns', \&verify_nvme_host_nqns);
sub type($class) {
return 'zfsnvme';
@@ -205,6 +237,13 @@ sub properties($class) {
format => 'pve-storage-nvme-host-ifaces',
maxLength => 512,
},
+ 'nvme-host-nqns' => {
+ description =>
+ "Comma-separated /etc/nvme/hostnqn values for every cluster node allowed to use this storage.",
+ type => 'string',
+ format => 'pve-storage-nvme-host-nqns',
+ maxLength => 8192,
+ },
'dhchap-key' => {
description => "NVMe DH-HMAC-CHAP key in secret representation format.",
type => 'string',
@@ -261,6 +300,7 @@ sub options($class) {
subsysnqn => { fixed => 1 },
'nvme-portals' => { fixed => 1 },
'nvme-host-ifaces' => { optional => 1 },
+ 'nvme-host-nqns' => { optional => 1 },
pool => { fixed => 1 },
blocksize => { fixed => 1 },
sparse => { optional => 1 },
@@ -306,6 +346,8 @@ sub check_config($class, $section_id, $config, $create, $skip_schema_check) {
} elsif (defined($config->{'nvme-host-ifaces'})) {
parse_nvme_host_ifaces($config->{'nvme-host-ifaces'});
}
+ parse_nvme_host_nqns($config->{'nvme-host-nqns'})
+ if defined($config->{'nvme-host-nqns'});
_validate_fail_fast_timeout($config, $create ? 600 : undef);
return $class->SUPER::check_config($section_id, $config, $create, $skip_schema_check);
}
@@ -392,6 +434,7 @@ my sub delete_secret($storeid) {
sub on_add_hook($class, $storeid, $scfg, %sensitive) {
_configured_portals($scfg);
+ parse_nvme_host_nqns($scfg->{'nvme-host-nqns'});
_assert_unique_target($storeid, $scfg);
set_secret($storeid, $sensitive{'dhchap-key'});
return;
@@ -402,6 +445,7 @@ sub on_update_hook_full($class, $storeid, $scfg, $update, $delete, $sensitive) {
delete @prospective{$delete->@*} if $delete;
verify_nvme_nqn($prospective{subsysnqn});
_configured_portals(\%prospective);
+ parse_nvme_host_nqns($prospective{'nvme-host-nqns'});
_validate_fail_fast_timeout(\%prospective, 600);
_assert_unique_target($storeid, \%prospective);
@@ -667,15 +711,23 @@ sub activate_storage($class, $storeid, $scfg, $cache = undef) {
my $hostid = file_read_firstline('/etc/nvme/hostid')
// die "missing /etc/nvme/hostid\n";
verify_nvme_nqn($hostnqn);
+ my $hostnqns = parse_nvme_host_nqns($scfg->{'nvme-host-nqns'});
+ my $local_host_is_allowed = grep { $_ eq $hostnqn } $hostnqns->@*;
+ die "local NVMe host NQN '$hostnqn' is missing from nvme-host-nqns\n"
+ if !$local_host_is_allowed;
my $key = get_secret($storeid);
my $target_portals = [
map { "$_->{family},$_->{address},$_->{port}" } $portals->@*
];
- PVE::Storage::LunCmd::NVMET::ensure_target($scfg, $hostnqn, $target_portals);
- PVE::Storage::LunCmd::NVMET::set_host_key($scfg, $hostnqn, $key);
- PVE::Storage::LunCmd::NVMET::allow_host($scfg, $hostnqn);
+ PVE::Storage::LunCmd::NVMET::ensure_target($scfg, $target_portals);
+ for my $allowed_hostnqn ($hostnqns->@*) {
+ PVE::Storage::LunCmd::NVMET::ensure_host($scfg, $allowed_hostnqn);
+ PVE::Storage::LunCmd::NVMET::set_host_key($scfg, $allowed_hostnqn, $key);
+ PVE::Storage::LunCmd::NVMET::allow_host($scfg, $allowed_hostnqn);
+ }
PVE::Storage::LunCmd::NVMET::reconcile($scfg);
+ PVE::Storage::LunCmd::NVMET::publish_target($scfg, $target_portals);
my $config_path =
write_runtime_config($storeid, $scfg, $hostnqn, $hostid, $key, $portals);
diff --git a/src/test/zfsnvme_test.pm b/src/test/zfsnvme_test.pm
index d64b149..3d69ec7 100644
--- a/src/test/zfsnvme_test.pm
+++ b/src/test/zfsnvme_test.pm
@@ -26,6 +26,18 @@ ok(
'rejects an invalid NQN',
);
+my $hostnqn_a = 'nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc';
+my $hostnqn_b = 'nqn.2014-08.org.nvmexpress:uuid:abcdefab-abcd-abcd-abcd-abcdefabcdef';
+is_deeply(
+ PVE::Storage::ZFSNVMePlugin::parse_nvme_host_nqns("$hostnqn_a,$hostnqn_b"),
+ [$hostnqn_a, $hostnqn_b],
+ 'parses the complete cluster NVMe host allow-list',
+);
+ok(
+ !PVE::Storage::ZFSNVMePlugin::parse_nvme_host_nqns("$hostnqn_a,$hostnqn_a", 1),
+ 'rejects duplicate NVMe host NQNs',
+);
+
is_deeply(
PVE::Storage::ZFSNVMePlugin::parse_nvme_portals(
'10.90.1.11:4420,[fd00::11]:4421,10.90.2.11',
@@ -421,6 +433,7 @@ is($@, '', 'fast I/O fail remains valid with infinite controller reconnect');
{
subsysnqn => 'nqn.2026-07.example:test',
'nvme-portals' => '10.90.1.11,10.90.2.11',
+ 'nvme-host-nqns' => $hostnqn_a,
},
{ 'nvme-host-ifaces' => 'ens20,ens21' },
undef,
@@ -445,7 +458,6 @@ PVE::Storage::LunCmd::NVMET::ensure_target(
subsysnqn => 'nqn.2026-07.example:test',
pool => 'tank/pve-nvme',
},
- 'nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc',
['ipv4,10.90.1.11,4420'],
);
like(
@@ -453,6 +465,32 @@ like(
qr/current_model.*current_serial.*refusing to take over existing NVMe subsystem/s,
'target reconcile verifies model and deterministic serial before taking over a subsystem',
);
+my ($ensure_port_body) = $helper_calls[0]->{input} =~ /^ensure_port\(\) \{\n(?<body>.*?)^\}/ms;
+ok(defined($ensure_port_body), 'remote helper contains the port preparation function');
+unlike(
+ $ensure_port_body,
+ qr{ln[ ]-s},
+ 'target setup does not publish a subsystem before ACL and namespace reconciliation',
+);
+like(
+ $helper_calls[0]->{input},
+ qr{^publish_target\(\).*?ln[ ]-s}ms,
+ 'the remote helper exposes the subsystem only in its publish operation',
+);
+
+@helper_calls = ();
+PVE::Storage::LunCmd::NVMET::publish_target(
+ {
+ server => '192.0.2.10',
+ subsysnqn => 'nqn.2026-07.example:test',
+ },
+ ['ipv4,10.90.1.11,4420'],
+);
+like(
+ join(' ', $helper_calls[0]->{cmd}->@*),
+ qr/publish-target/,
+ 'publishing the fully reconciled target is an explicit final operation',
+);
my ($key_cmd, %key_opts);
$nvmet_mock->redefine(
--
2.54.0.windows.1
prev parent reply other threads:[~2026-08-02 3:33 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-02 3:31 [PATCH storage v2 0/7] add native ZFS over NVMe/TCP backend Joaquin Varela
2026-08-02 3:31 ` [PATCH storage v2 1/7] zfs: make LUN provider dispatch overridable Joaquin Varela
2026-08-02 3:31 ` [PATCH storage v2 2/7] zfs: add native NVMe/TCP storage backend Joaquin Varela
2026-08-02 3:31 ` [PATCH storage v2 3/7] zfsnvme: harden node preflight and storage teardown Joaquin Varela
2026-08-02 3:31 ` [PATCH storage v2 4/7] zfsnvme: make all-path loss policy explicit Joaquin Varela
2026-08-02 3:31 ` [PATCH storage v2 5/7] zfsnvme: accept activation hints from storage API Joaquin Varela
2026-08-02 3:31 ` [PATCH storage v2 6/7] zfsnvme: accept short volume activation calls Joaquin Varela
2026-08-02 3:31 ` Joaquin Varela [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=c49747888d51f922f579839a890f9e5096dc35cf.1785636979.git.joaquinvarela@neatech.ar \
--to=joaquinvarela@neatech.ar \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox