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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.