From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 4B0391FF0AF for ; Fri, 06 Nov 2026 01:54:40 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 852F821696; Fri, 06 Nov 2026 01:51:10 +0100 (CET) From: Thomas Lamprecht 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 Message-ID: <20261105233336.2178064-27-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926178953 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.165 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: Y3OMN2ZIIMXIEOZ7Q2UJAUSKJ2QICV5N X-Message-ID-Hash: Y3OMN2ZIIMXIEOZ7Q2UJAUSKJ2QICV5N X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Dietmar Maurer 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 [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 --- 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