From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance
Date: Fri, 6 Nov 2026 00:31:01 +0100 [thread overview]
Message-ID: <20261105233336.2178064-27-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>
From: Dietmar Maurer <dietmar@proxmox.com>
iSCSI storage activation logs in automatically, but inspecting sessions
and removing leftover node records requires iscsiadm on the shell.
Expose status and maintenance operations at /nodes/{node}/iscsi:
initiator identity, targets and portals with session state, transport
and storage ownership, negotiated parameters, session rescans, and
node-record removal per target or portal. Merge node records with active
sessions so disconnected targets remain visible. The session list now
records each session's transport for this status.
Normalize portal addresses before merging: iscsiadm uses bracketed or
bare IPv6 and IPv4-mapped addresses in different listings. For record
removal, rewrite an IPv4-mapped address's tail as hex groups, the only
form iscsiadm accepts with a port.
Allow reads with Sys.Audit on the node or Datastore.Allocate on /storage
so node auditors and storage administrators can use the SAN views.
Rescan and record removal require Sys.Modify.
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
[TL: report the initiator name and alias as top-level fields, leave out
the session state 'logging-out' that is never reported, and also allow
reads with Sys.Audit on the node; move the iscsiadm helpers into
ISCSIPlugin; omit logout until its guarded implementation and adapt the
message; clarify support and node-record removal descriptions; record
the session transport]
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
src/PVE/API2/ISCSI.pm | 414 ++++++++++++++++++++++++++++
src/PVE/API2/Makefile | 1 +
src/PVE/Storage/ISCSIPlugin.pm | 156 ++++++++++-
src/test/run_iscsi_session_tests.pl | 15 +-
src/test/run_san_api_tests.pl | 88 ++++++
5 files changed, 665 insertions(+), 9 deletions(-)
create mode 100644 src/PVE/API2/ISCSI.pm
diff --git a/src/PVE/API2/ISCSI.pm b/src/PVE/API2/ISCSI.pm
new file mode 100644
index 00000000..28c5a6e4
--- /dev/null
+++ b/src/PVE/API2/ISCSI.pm
@@ -0,0 +1,414 @@
+package PVE::API2::ISCSI;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RESTHandler;
+use PVE::Storage;
+use PVE::Storage::ISCSIPlugin;
+use PVE::Tools qw(run_command file_get_contents file_read_firstline);
+
+use base qw(PVE::RESTHandler);
+
+my $ISCSIADM = '/usr/bin/iscsiadm';
+my $INITIATOR_NAME_FILE = '/etc/iscsi/initiatorname.iscsi';
+
+my $session_id_schema = {
+ description => "Kernel iSCSI session id.",
+ type => 'integer',
+ minimum => 0,
+};
+
+my $target_schema = {
+ description => "iSCSI target name (IQN or EUI).",
+ type => 'string',
+ minLength => 1,
+ maxLength => 255,
+};
+
+my $status_return = {
+ type => 'object',
+ properties => {
+ supported => {
+ description => "Whether open-iscsi is installed on this node.",
+ type => 'boolean',
+ },
+ 'initiator-name' => {
+ description => "The initiator IQN (InitiatorName), if available.",
+ type => 'string',
+ optional => 1,
+ },
+ 'initiator-alias' => {
+ description => "The initiator alias (InitiatorAlias), if set.",
+ type => 'string',
+ optional => 1,
+ },
+ },
+};
+
+my $target_return = {
+ type => 'object',
+ properties => {
+ target => {
+ description => "The target IQN.",
+ type => 'string',
+ },
+ portal => {
+ description => "The portal ('host:port') this entry refers to.",
+ type => 'string',
+ },
+ 'session-id' => {
+ description => "The kernel session id; absent when there is no active session"
+ . " on this portal.",
+ type => 'integer',
+ optional => 1,
+ },
+ transport => {
+ description => "The transport the session runs over (for example 'tcp' or"
+ . " 'iser'); only set for active sessions.",
+ type => 'string',
+ optional => 1,
+ },
+ health => {
+ description => "The session health derived from the kernel session state:"
+ . " 'recovering' means the connection is down and iscsid is retrying the"
+ . " login, 'not-connected' means only a node record exists, without an"
+ . " active session.",
+ type => 'string',
+ enum => ['logged-in', 'recovering', 'not-connected', 'unknown'],
+ },
+ 'kernel-state' => {
+ description => "The raw kernel session state read from sysfs (for example"
+ . " 'LOGGED_IN'), if available.",
+ type => 'string',
+ optional => 1,
+ },
+ 'used-by-storage' => {
+ description => "Storage identifier of a configured iSCSI-type storage using"
+ . " this target.",
+ type => 'string',
+ optional => 1,
+ },
+ },
+};
+
+my $session_params_return = {
+ type => 'object',
+ properties => {
+ 'header-digest' => {
+ description => "The negotiated header digest.",
+ type => 'string',
+ optional => 1,
+ },
+ 'data-digest' => {
+ description => "The negotiated data digest.",
+ type => 'string',
+ optional => 1,
+ },
+ 'max-recv-data-segment-length' => {
+ description => "Max data segment length (bytes) the initiator accepts in a PDU.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'max-xmit-data-segment-length' => {
+ description => "Max data segment length (bytes) the target accepts in a PDU.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'first-burst-length' => {
+ description => "Max unsolicited data (bytes) sent with a command (first burst).",
+ type => 'integer',
+ optional => 1,
+ },
+ 'max-burst-length' => {
+ description => "Max data (bytes) in one solicited burst.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'max-outstanding-r2t' => {
+ description => "Max number of outstanding Ready-To-Transfer PDUs.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'immediate-data' => {
+ description => "Whether unsolicited immediate data is allowed ('Yes'/'No').",
+ type => 'string',
+ optional => 1,
+ },
+ 'initial-r2t' => {
+ description =>
+ "Whether the target requires an R2T before any data is sent ('Yes'/'No').",
+ type => 'string',
+ optional => 1,
+ },
+ 'recovery-timeout' => {
+ description =>
+ "Seconds the session waits for path recovery before failing its commands.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'abort-timeout' => {
+ description => "Seconds to wait for a SCSI abort task to complete.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'lu-reset-timeout' => {
+ description => "Seconds to wait for a LUN reset to complete.",
+ type => 'integer',
+ optional => 1,
+ },
+ 'tgt-reset-timeout' => {
+ description => "Seconds to wait for a target reset to complete.",
+ type => 'integer',
+ optional => 1,
+ },
+ },
+};
+
+my sub read_initiator_info {
+ my $content = eval { file_get_contents($INITIATOR_NAME_FILE) };
+ return undef if !defined($content);
+
+ my $res = {};
+ for my $line (split(/\n/, $content)) {
+ if ($line =~ m/^\s*InitiatorName\s*=\s*(\S+)\s*$/) {
+ $res->{name} = $1;
+ } elsif ($line =~ m/^\s*InitiatorAlias\s*=\s*(.+?)\s*$/) {
+ $res->{alias} = $1;
+ }
+ }
+ return defined($res->{name}) ? $res : undef;
+}
+
+my sub session_health {
+ my ($kernel_state) = @_;
+
+ return 'unknown' if !defined($kernel_state);
+ return 'logged-in' if $kernel_state eq 'LOGGED_IN';
+ return 'recovering' if $kernel_state eq 'FAILED';
+ return 'not-connected' if $kernel_state eq 'FREE';
+ return 'unknown';
+}
+
+my sub list_targets {
+ my $session_map = PVE::Storage::ISCSIPlugin::iscsi_session_list();
+ my $node_list = PVE::Storage::ISCSIPlugin::iscsi_node_list();
+
+ my $target_storage = {};
+ my $cfg = PVE::Storage::config();
+ for my $storeid (sort keys %{ $cfg->{ids} }) {
+ my $scfg = $cfg->{ids}->{$storeid};
+ next if $scfg->{type} ne 'iscsi' || !defined($scfg->{target});
+ $target_storage->{ $scfg->{target} } //= $storeid;
+ }
+
+ my $res = [];
+ my $have_session = {};
+ for my $target (sort keys %$session_map) {
+ for my $session (@{ $session_map->{$target} }) {
+ my $sid = int($session->{session_id});
+ my $state = file_read_firstline("/sys/class/iscsi_session/session${sid}/state");
+ my $portal = PVE::Storage::ISCSIPlugin::iscsi_normalize_portal($session->{portal});
+ my $entry = {
+ target => $target,
+ 'session-id' => $sid,
+ portal => $portal,
+ transport => $session->{transport},
+ health => session_health($state),
+ };
+ $entry->{'kernel-state'} = $state if defined($state);
+ $entry->{'used-by-storage'} = $target_storage->{$target}
+ if defined($target_storage->{$target});
+ push @$res, $entry;
+ $have_session->{"$target\0$portal"} = 1;
+ }
+ }
+
+ for my $node (@$node_list) {
+ my $portal = PVE::Storage::ISCSIPlugin::iscsi_normalize_portal($node->{portal});
+ next if $have_session->{"$node->{target}\0$portal"};
+ my $entry = {
+ target => $node->{target},
+ portal => $portal,
+ health => 'not-connected',
+ };
+ $entry->{'used-by-storage'} = $target_storage->{ $node->{target} }
+ if defined($target_storage->{ $node->{target} });
+ push @$res, $entry;
+ }
+
+ return [sort { $a->{target} cmp $b->{target} || $a->{portal} cmp $b->{portal} } @$res];
+}
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ proxyto => 'node',
+ permissions => { user => 'all' },
+ description => "Node iSCSI initiator index.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {},
+ },
+ links => [{ rel => 'child', href => "{name}" }],
+ },
+ code => sub {
+ return [
+ { name => 'rescan' },
+ { name => 'session-params' },
+ { name => 'status' },
+ { name => 'target' },
+ ];
+ },
+});
+
+my $read_permissions = {
+ check => [
+ 'or',
+ ['perm', '/nodes/{node}', ['Sys.Audit']],
+ ['perm', '/storage', ['Datastore.Allocate']],
+ ],
+};
+__PACKAGE__->register_method({
+ name => 'status',
+ path => 'status',
+ method => 'GET',
+ protected => 1,
+ proxyto => 'node',
+ description => "Report iSCSI support and, when open-iscsi is installed, the"
+ . " initiator identity.",
+ permissions => $read_permissions,
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ },
+ },
+ returns => $status_return,
+ code => sub {
+ my $res = { supported => PVE::Storage::ISCSIPlugin::iscsi_supported() };
+ if ($res->{supported}) {
+ my $initiator = read_initiator_info();
+ if ($initiator) {
+ $res->{'initiator-name'} = $initiator->{name} if defined($initiator->{name});
+ $res->{'initiator-alias'} = $initiator->{alias} if defined($initiator->{alias});
+ }
+ }
+ return $res;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'targets',
+ path => 'target',
+ method => 'GET',
+ protected => 1,
+ proxyto => 'node',
+ description => "List the iSCSI targets known to the initiator, together with the state"
+ . " of their sessions.",
+ permissions => $read_permissions,
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ },
+ },
+ returns => {
+ type => 'array',
+ items => $target_return,
+ },
+ code => sub {
+ return list_targets();
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'target_delete',
+ path => 'target',
+ method => 'DELETE',
+ protected => 1,
+ proxyto => 'node',
+ description => "Remove a target's node records from the initiator database, for one portal"
+ . " or all portals. Log out of the affected sessions first.",
+ permissions => {
+ check => ['perm', '/', ['Sys.Modify']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ target => $target_schema,
+ portal => {
+ description => "Only remove the node record of this portal.",
+ type => 'string',
+ minLength => 2,
+ maxLength => 256,
+ optional => 1,
+ },
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+ return PVE::Storage::ISCSIPlugin::iscsi_delete_records($param->{target},
+ $param->{portal});
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'session_params',
+ path => 'session-params',
+ method => 'GET',
+ protected => 1,
+ proxyto => 'node',
+ description => "Read the parameters an active iSCSI session negotiated at login.",
+ permissions => $read_permissions,
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ 'session-id' => $session_id_schema,
+ },
+ },
+ returns => $session_params_return,
+ code => sub {
+ my ($param) = @_;
+ return PVE::Storage::ISCSIPlugin::iscsi_session_parameters($param->{'session-id'});
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'rescan',
+ path => 'rescan',
+ method => 'POST',
+ protected => 1,
+ proxyto => 'node',
+ description => "Rescan an iSCSI session to pick up newly attached LUNs.",
+ permissions => {
+ check => ['perm', '/', ['Sys.Modify']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ 'session-id' => $session_id_schema,
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+ return PVE::Storage::ISCSIPlugin::iscsi_rescan_session($param->{'session-id'});
+ },
+});
+
+1;
diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index fe316c52..10e18f95 100644
--- a/src/PVE/API2/Makefile
+++ b/src/PVE/API2/Makefile
@@ -3,5 +3,6 @@
.PHONY: install
install:
install -D -m 0644 Disks.pm ${DESTDIR}${PERLDIR}/PVE/API2/Disks.pm
+ install -D -m 0644 ISCSI.pm ${DESTDIR}${PERLDIR}/PVE/API2/ISCSI.pm
make -C Storage install
make -C Disks install
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 22f6c44f..e7d1ff3f 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -50,10 +50,15 @@ sub iscsi_session_list {
outfunc => sub {
my $line = shift;
# example: tcp: [1] 192.168.122.252:3260,1 iqn.2003-01.org.linux-iscsi.proxmox-nfs.x8664:sn.00567885ba8f (non-flash)
- if ($line =~ m/^\S+:\s+\[(\S+)\]\s+(\S+:\d+)\,\S+\s+(\S+)\s+\S+?\s*$/) {
- my ($session_id, $portal, $target) = ($1, $2, $3);
+ if ($line =~ m/^(\S+):\s+\[(\S+)\]\s+(\S+:\d+)\,\S+\s+(\S+)\s+\S+?\s*$/) {
+ my ($transport, $session_id, $portal, $target) = ($1, $2, $3, $4);
# there can be several sessions per target (multipath)
- push @{ $res->{$target} }, { session_id => $session_id, portal => $portal };
+ push @{ $res->{$target} },
+ {
+ session_id => $session_id,
+ portal => $portal,
+ transport => $transport,
+ };
}
},
);
@@ -65,6 +70,151 @@ sub iscsi_session_list {
return $res;
}
+sub iscsi_node_list {
+ assert_iscsi_support();
+
+ my $res = [];
+ eval {
+ run_command(
+ [$ISCSIADM, '--mode', 'node'],
+ errmsg => 'iscsi node record scan failed',
+ outfunc => sub {
+ my $line = shift;
+ if ($line =~ $ISCSI_TARGET_RE) {
+ push @$res, { portal => $1, target => $2 };
+ }
+ },
+ );
+ };
+ if (my $err = $@) {
+ die $err if $err !~ m/: No records found/i;
+ }
+
+ return $res;
+}
+
+sub iscsi_supported {
+ return -x $ISCSIADM ? 1 : 0;
+}
+
+# Session and node listings spell IPv6 differently; normalize brackets and mapped IPv4 for merging.
+sub iscsi_normalize_portal {
+ my ($portal) = @_;
+ return $portal if $portal !~ m/^(.+):(\d+)$/;
+ my ($addr, $port) = ($1, $2);
+ $addr =~ s/^\[(.*)\]$/$1/;
+ $addr =~ s/^::ffff:(\d+\.\d+\.\d+\.\d+)$/$1/i;
+ $addr = "[$addr]" if $addr =~ m/:/;
+ return "$addr:$port";
+}
+
+# iscsiadm accepts an IPv6 port only with brackets and no dots in the address. Encode an embedded
+# IPv4 tail as hex groups to address the stored record.
+my sub iscsiadm_portal_arg {
+ my ($portal) = @_;
+ my ($addr, $port) = $portal =~ m/^(.+):(\d+)$/;
+ return $portal if !defined($addr) || $addr !~ m/:/;
+ if ($addr =~ m/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/) {
+ $addr = sprintf("%s%x:%x", $1, ($2 << 8) + $3, ($4 << 8) + $5);
+ }
+ return "[$addr]:$port";
+}
+
+# Field names from the Timeouts and Negotiated iSCSI params sections of iscsiadm -m session -P 2.
+my $session_param_map = {
+ 'HeaderDigest' => ['header-digest', 'string'],
+ 'DataDigest' => ['data-digest', 'string'],
+ 'MaxRecvDataSegmentLength' => ['max-recv-data-segment-length', 'integer'],
+ 'MaxXmitDataSegmentLength' => ['max-xmit-data-segment-length', 'integer'],
+ 'FirstBurstLength' => ['first-burst-length', 'integer'],
+ 'MaxBurstLength' => ['max-burst-length', 'integer'],
+ 'ImmediateData' => ['immediate-data', 'string'],
+ 'InitialR2T' => ['initial-r2t', 'string'],
+ 'MaxOutstandingR2T' => ['max-outstanding-r2t', 'integer'],
+ 'Recovery Timeout' => ['recovery-timeout', 'integer'],
+ 'Abort Timeout' => ['abort-timeout', 'integer'],
+ 'LUN Reset Timeout' => ['lu-reset-timeout', 'integer'],
+ 'Target Reset Timeout' => ['tgt-reset-timeout', 'integer'],
+};
+
+sub iscsi_session_parameters {
+ my ($session_id) = @_;
+
+ my $res = {};
+ run_command(
+ [$ISCSIADM, '--mode', 'session', '--sid', $session_id, '-P', '2'],
+ errmsg => "reading parameters of iSCSI session $session_id failed",
+ outfunc => sub {
+ my ($line) = @_;
+ return if $line !~ m/^\s*([^:]+?)\s*:\s*(.+?)\s*$/;
+ my ($key, $value) = ($1, $2);
+ my $field = $session_param_map->{$key} or return;
+ my ($name, $type) = @$field;
+ if ($type eq 'integer') {
+ return if $value !~ m/^\d+$/;
+ $value = int($value);
+ }
+ $res->{$name} = $value;
+ },
+ );
+ return $res;
+}
+
+sub iscsi_delete_records {
+ my ($target, $portal) = @_;
+
+ my $sessions = iscsi_session_list()->{$target} // [];
+ for my $session (@$sessions) {
+ next if defined($portal) && iscsi_normalize_portal($session->{portal}) ne $portal;
+ die "target '$target' still has an active session - log out first\n";
+ }
+
+ if (!defined($portal)) {
+ run_command(
+ [$ISCSIADM, '--mode', 'node', '--targetname', $target, '--op', 'delete'],
+ errmsg => "removing the node records of target '$target' failed",
+ outfunc => sub { },
+ );
+ return undef;
+ }
+
+ # Compare normalized portals but address records in their stored representation.
+ my @records =
+ grep { $_->{target} eq $target && iscsi_normalize_portal($_->{portal}) eq $portal }
+ @{ iscsi_node_list() };
+ die "no node record for target '$target', portal '$portal' found\n" if !scalar(@records);
+
+ for my $record (@records) {
+ run_command(
+ [
+ $ISCSIADM,
+ '--mode',
+ 'node',
+ '--targetname',
+ $target,
+ '--portal',
+ iscsiadm_portal_arg($record->{portal}),
+ '--op',
+ 'delete',
+ ],
+ errmsg => "removing the node record of target '$target' failed",
+ outfunc => sub { },
+ );
+ }
+ return undef;
+}
+
+sub iscsi_rescan_session {
+ my ($sid) = @_;
+
+ run_command(
+ [$ISCSIADM, '--mode', 'session', '--sid', $sid, '--rescan'],
+ errmsg => "rescan of iSCSI session $sid failed",
+ outfunc => sub { },
+ );
+ return undef;
+}
+
sub iscsi_test_session {
my ($sid) = @_;
diff --git a/src/test/run_iscsi_session_tests.pl b/src/test/run_iscsi_session_tests.pl
index a338b9b4..c68b54fc 100755
--- a/src/test/run_iscsi_session_tests.pl
+++ b/src/test/run_iscsi_session_tests.pl
@@ -37,20 +37,23 @@ my sub sessions {
for my $transport (qw(tcp iser bnx2i be2iscsi cxgb4i qedi qla4xxx)) {
is_deeply(
sessions("$transport: [3] 192.0.2.1:3260,1 iqn.test (non-flash)"),
- { 'iqn.test' => [{ session_id => 3, portal => '192.0.2.1:3260' }] },
+ {
+ 'iqn.test' =>
+ [{ session_id => 3, portal => '192.0.2.1:3260', transport => $transport }],
+ },
"$transport session listed",
);
}
is_deeply(
sessions('qla4xxx: [4] 192.0.2.1:3260,1 iqn.test (flash)'),
- { 'iqn.test' => [{ session_id => 4, portal => '192.0.2.1:3260' }] },
+ { 'iqn.test' => [{ session_id => 4, portal => '192.0.2.1:3260', transport => 'qla4xxx' }] },
'flash node session listed',
);
is_deeply(
sessions('iser: [2] [2001:db8::1]:3260,1 iqn.test (non-flash)'),
- { 'iqn.test' => [{ session_id => 2, portal => '[2001:db8::1]:3260' }] },
+ { 'iqn.test' => [{ session_id => 2, portal => '[2001:db8::1]:3260', transport => 'iser' }] },
'IPv6 portal keeps its brackets',
);
@@ -62,10 +65,10 @@ is_deeply(
),
{
'iqn.a' => [
- { session_id => 1, portal => '192.0.2.1:3260' },
- { session_id => 2, portal => '192.0.2.2:3260' },
+ { session_id => 1, portal => '192.0.2.1:3260', transport => 'tcp' },
+ { session_id => 2, portal => '192.0.2.2:3260', transport => 'iser' },
],
- 'iqn.b' => [{ session_id => 3, portal => '192.0.2.3:3260' }],
+ 'iqn.b' => [{ session_id => 3, portal => '192.0.2.3:3260', transport => 'bnx2i' }],
},
'sessions of one target are grouped across transports',
);
diff --git a/src/test/run_san_api_tests.pl b/src/test/run_san_api_tests.pl
index ac5a8be3..00d719a0 100755
--- a/src/test/run_san_api_tests.pl
+++ b/src/test/run_san_api_tests.pl
@@ -7,10 +7,26 @@ use Test::MockModule;
use Test::More;
use lib ('.', '..');
+use PVE::API2::ISCSI;
use PVE::API2::Storage::Scan;
+subtest 'iSCSI status fields' => sub {
+ my $method = PVE::API2::ISCSI->map_method_by_name('status');
+ ok($method->{returns}->{properties}->{'initiator-name'}, 'name schema');
+ ok($method->{returns}->{properties}->{'initiator-alias'}, 'alias schema');
+ my $enum =
+ PVE::API2::ISCSI->map_method_by_name('targets')->{returns}->{items}->{properties}->{health}
+ ->{enum};
+ is_deeply(
+ $enum,
+ ['logged-in', 'recovering', 'not-connected', 'unknown'],
+ 'only produced session states',
+ );
+};
+
subtest 'node SAN read permissions' => sub {
for my $case (
+ ['PVE::API2::ISCSI', qw(status targets session_params)],
['PVE::API2::Storage::Scan', 'sanlunscan'],
) {
my ($class, @methods) = @$case;
@@ -26,6 +42,18 @@ subtest 'node SAN read permissions' => sub {
);
}
}
+ for my $case (
+ ['PVE::API2::ISCSI', qw(target_delete rescan)],
+ ) {
+ my ($class, @methods) = @$case;
+ for my $name (@methods) {
+ is_deeply(
+ $class->map_method_by_name($name)->{permissions}->{check},
+ ['perm', '/', ['Sys.Modify']],
+ "$class $name write gate",
+ );
+ }
+ }
};
subtest 'SAN CLI columns leave machine output complete' => sub {
@@ -94,4 +122,64 @@ subtest 'SAN LUN path counts' => sub {
}
};
+subtest 'iSCSI transport helpers preserve command arguments' => sub {
+ my $plugin = Test::MockModule->new('PVE::Storage::ISCSIPlugin');
+ my (@commands, $sessions);
+ $plugin->mock(
+ iscsi_session_list => sub { { 'iqn.test' => $sessions // [] } },
+ iscsi_node_list =>
+ sub { [{ target => 'iqn.test', portal => '::ffff:192.0.2.1:3260' }] },
+ run_command => sub {
+ my ($cmd, %param) = @_;
+ push @commands, $cmd;
+ $param{outfunc}->($_)
+ for 'HeaderDigest: None', 'Recovery Timeout: 120',
+ 'MaxBurstLength: invalid';
+ },
+ );
+ my $call = sub { PVE::API2::ISCSI->map_method_by_name($_[0])->{code}->($_[1]) };
+ is_deeply(
+ $call->('session_params', { 'session-id' => 7 }),
+ { 'header-digest' => 'None', 'recovery-timeout' => 120 },
+ 'session parser retains recognized values and rejects invalid integers',
+ );
+ is_deeply(
+ $commands[-1],
+ ['/usr/bin/iscsiadm', '--mode', 'session', '--sid', 7, '-P', '2'],
+ 'session query arguments',
+ );
+ $call->('rescan', { 'session-id' => 7 });
+ is_deeply(
+ $commands[-1],
+ ['/usr/bin/iscsiadm', '--mode', 'session', '--sid', 7, '--rescan'],
+ 'rescan arguments',
+ );
+ $call->('target_delete', { target => 'iqn.test', portal => '192.0.2.1:3260' });
+ is_deeply(
+ $commands[-1],
+ [
+ '/usr/bin/iscsiadm',
+ '--mode',
+ 'node',
+ '--targetname',
+ 'iqn.test',
+ '--portal',
+ '[::ffff:c000:201]:3260',
+ '--op',
+ 'delete',
+ ],
+ 'mapped IPv4 record uses its stored address with an iscsiadm-compatible spelling',
+ );
+ $sessions = [{ portal => '[::ffff:192.0.2.1]:3260' }];
+ @commands = ();
+ eval { $call->('target_delete', { target => 'iqn.test', portal => '192.0.2.1:3260' }) };
+ like($@, qr/still has an active session/, 'active normalized portal refuses deletion');
+ is_deeply(\@commands, [], 'refusal does not invoke delete');
+ is(
+ PVE::Storage::ISCSIPlugin::iscsi_normalize_portal('2001:db8::1:3260'),
+ '[2001:db8::1]:3260',
+ 'IPv6 comparison key',
+ );
+};
+
done_testing();
--
2.47.3
next prev parent reply other threads:[~2026-11-06 0:54 UTC|newest]
Thread overview: 77+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox 01/76] add proxmox-multipath crate Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 06/76] storage: bound LVM status commands Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` Thomas Lamprecht [this message]
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 33/76] multipath: publish per-node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Thomas Lamprecht
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=20261105233336.2178064-27-t.lamprecht@proxmox.com \
--to=t.lamprecht@proxmox.com \
--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.