* [PATCH v2 cluster 01/15] mapping: add storage.cfg
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
@ 2026-04-30 17:26 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 02/15] mapping: add base plugin Mira Limbeck
` (13 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:26 UTC (permalink / raw)
To: pve-devel
To support a cluster-wide storage mapping config, a new file in /etc/pve
is needed. Since storage mappings are in design similar to PCI, USB and
directory mappings, it is best located next to those under
mapping/storage.cfg.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
v2:
- no change
src/PVE/Cluster.pm | 1 +
src/pmxcfs/status.c | 1 +
2 files changed, 2 insertions(+)
diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index c01ceaf..d88b9e3 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -89,6 +89,7 @@ my $observed = {
'mapping/directory.cfg' => 1,
'mapping/pci.cfg' => 1,
'mapping/usb.cfg' => 1,
+ 'mapping/storage.cfg' => 1,
};
sub prepare_observed_file_basedirs {
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index bb68445..02b7b00 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -117,6 +117,7 @@ static memdb_change_t memdb_change_array[] = {
{.path = "mapping/directory.cfg"},
{.path = "mapping/pci.cfg"},
{.path = "mapping/usb.cfg"},
+ {.path = "mapping/storage.cfg"},
};
static GMutex mutex;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 02/15] mapping: add base plugin
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
2026-04-30 17:26 ` [PATCH v2 cluster 01/15] mapping: add storage.cfg Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:35 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 03/15] mapping: add iSCSI plugin Mira Limbeck
` (12 subsequent siblings)
14 siblings, 1 reply; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
For some storages, for example iSCSI, it can make sense to have a
per-node mapping rather than a cluster-wide storage configuration. This
allows for example to have different portals and targets for each node,
that all map to the same SAN and backing storage on the SAN.
To facilitate such a setup we introduce mappings via a base mapping
plugin that can be extended for each type of storage.
---
src/PVE/Storage.pm | 3 ++
src/PVE/Storage/Makefile | 4 +-
src/PVE/Storage/Mapping.pm | 44 ++++++++++++++++++
src/PVE/Storage/Mapping/Makefile | 6 +++
src/PVE/Storage/Mapping/Plugin.pm | 74 +++++++++++++++++++++++++++++++
src/PVE/Storage/Plugin.pm | 6 +++
6 files changed, 136 insertions(+), 1 deletion(-)
create mode 100644 src/PVE/Storage/Mapping.pm
create mode 100644 src/PVE/Storage/Mapping/Makefile
create mode 100644 src/PVE/Storage/Mapping/Plugin.pm
diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index 6e87bac..d783788 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -24,6 +24,9 @@ use PVE::RPCEnvironment;
use PVE::SSHInfo;
use PVE::RESTEnvironment qw(log_warn);
+# registers Mapping Plugins
+use PVE::Storage::Mapping;
+
use PVE::Storage::Plugin;
use PVE::Storage::DirPlugin;
use PVE::Storage::LVMPlugin;
diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
index a67dc25..c5861da 100644
--- a/src/PVE/Storage/Makefile
+++ b/src/PVE/Storage/Makefile
@@ -14,10 +14,12 @@ SOURCES= \
PBSPlugin.pm \
BTRFSPlugin.pm \
LvmThinPlugin.pm \
- ESXiPlugin.pm
+ ESXiPlugin.pm \
+ Mapping.pm
.PHONY: install
install:
make -C Common install
for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Storage/$$i; done
make -C LunCmd install
+ make -C Mapping install
diff --git a/src/PVE/Storage/Mapping.pm b/src/PVE/Storage/Mapping.pm
new file mode 100644
index 0000000..b607156
--- /dev/null
+++ b/src/PVE/Storage/Mapping.pm
@@ -0,0 +1,44 @@
+package PVE::Storage::Mapping;
+
+use PVE::JSONSchema;
+
+use PVE::Storage::Mapping::ISCSI;
+use PVE::Storage::Mapping::Plugin;
+
+PVE::Storage::Mapping::ISCSI->register();
+PVE::Storage::Mapping::Plugin->init(property_isolation => 1);
+
+sub find_mapping_on_current_node {
+ my ($id) = @_;
+
+ my $cfg = PVE::Storage::Mapping::Plugin::config();
+ my $nodename = PVE::INotify::nodename();
+
+ return get_node_mapping($cfg, $id, $nodename);
+}
+
+sub get_node_mapping {
+ my ($cfg, $id, $nodename) = @_;
+
+ my $mapping = $cfg->{ids}->{$id};
+ return undef if !defined($mapping);
+
+ my $plugin_type = $cfg->{ids}->{$id}->{type};
+ my $plugin = PVE::Storage::Mapping::Plugin->lookup($plugin_type);
+
+ my $map_key = $plugin->get_map_key();
+ my $map_fmt = $plugin->get_map_format();
+ warn "no '$map_key' property found\n" if !$map_fmt;
+
+ my $res = [];
+ for my $map ($mapping->{$map_key}->@*) {
+ my $entry = eval { PVE::JSONSchema::parse_property_string($map_fmt, $map) };
+ warn $@ if $@;
+ if ($entry && $entry->{node} eq $nodename) {
+ push $res->@*, $entry;
+ }
+ }
+ return $res;
+}
+
+1;
diff --git a/src/PVE/Storage/Mapping/Makefile b/src/PVE/Storage/Mapping/Makefile
new file mode 100644
index 0000000..168bea6
--- /dev/null
+++ b/src/PVE/Storage/Mapping/Makefile
@@ -0,0 +1,6 @@
+SOURCES= \
+ Plugin.pm
+
+.PHONY: install
+install:
+ for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Storage/Mapping/$$i; done
diff --git a/src/PVE/Storage/Mapping/Plugin.pm b/src/PVE/Storage/Mapping/Plugin.pm
new file mode 100644
index 0000000..2da2e26
--- /dev/null
+++ b/src/PVE/Storage/Mapping/Plugin.pm
@@ -0,0 +1,74 @@
+package PVE::Storage::Mapping::Plugin;
+
+use strict;
+use warnings;
+
+use PVE::Storage::Mapping::ISCSI;
+use PVE::INotify;
+use PVE::JSONSchema;
+use PVE::Cluster qw(
+ cfs_lock_file
+ cfs_read_file
+ cfs_register_file
+ cfs_write_file
+);
+
+use base qw(PVE::SectionConfig);
+
+my $FILENAME = 'mapping/storage.cfg';
+
+cfs_register_file(
+ $FILENAME,
+ sub { __PACKAGE__->parse_config(@_); },
+ sub { __PACKAGE__->write_config(@_); },
+);
+
+# from PVE::Storage::Plugin
+sub parse_section_header {
+ my ($class, $line) = @_;
+
+ if ($line =~ m/^(\S+):\s*(\S+)\s*$/) {
+ my ($type, $storeid) = (lc($1), $2);
+ my $errmsg = undef; # set if you want to skip whole section
+ eval { PVE::JSONSchema::parse_storage_id($storeid); };
+ $errmsg = $@ if $@;
+ my $config = {}; # to return additional attributes
+ return ($type, $storeid, $errmsg, $config);
+ }
+ return undef;
+}
+
+my $defaultData = {
+ propertyList => {
+ type => { description => "Storage type." },
+ id => {
+ description => "The ID of the logical storage mapping.",
+ type => 'string',
+ format => 'pve-storage-id',
+ },
+ description => {
+ description => "Description of the logical storage.",
+ type => 'string',
+ optional => 1,
+ maxLength => 4096,
+ },
+ },
+};
+
+sub private {
+ return $defaultData;
+}
+
+sub config {
+ return cfs_read_file($FILENAME);
+}
+
+sub get_map_key {
+ return 'map';
+}
+
+sub get_map_format {
+ die "implement in subclass\n";
+}
+
+1;
diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
index afd3141..61cda22 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -247,6 +247,12 @@ my $defaultData = {
default => 0,
optional => 1,
},
+ mapping => {
+ description => "Logical per-node storage mapping.",
+ type => 'string',
+ format => 'pve-storage-id',
+ optional => 1,
+ },
},
};
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* Re: [PATCH v2 storage 02/15] mapping: add base plugin
2026-04-30 17:27 ` [PATCH v2 storage 02/15] mapping: add base plugin Mira Limbeck
@ 2026-04-30 17:35 ` Mira Limbeck
0 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:35 UTC (permalink / raw)
To: pve-devel
On 4/30/26 7:32 PM, Mira Limbeck wrote:
> For some storages, for example iSCSI, it can make sense to have a
> per-node mapping rather than a cluster-wide storage configuration. This
> allows for example to have different portals and targets for each node,
> that all map to the same SAN and backing storage on the SAN.
>
> To facilitate such a setup we introduce mappings via a base mapping
> plugin that can be extended for each type of storage.
>
> ---
> src/PVE/Storage.pm | 3 ++
> src/PVE/Storage/Makefile | 4 +-
> src/PVE/Storage/Mapping.pm | 44 ++++++++++++++++++
> src/PVE/Storage/Mapping/Makefile | 6 +++
> src/PVE/Storage/Mapping/Plugin.pm | 74 +++++++++++++++++++++++++++++++
> src/PVE/Storage/Plugin.pm | 6 +++
> 6 files changed, 136 insertions(+), 1 deletion(-)
> create mode 100644 src/PVE/Storage/Mapping.pm
> create mode 100644 src/PVE/Storage/Mapping/Makefile
> create mode 100644 src/PVE/Storage/Mapping/Plugin.pm
>
> diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
> index 6e87bac..d783788 100755
> --- a/src/PVE/Storage.pm
> +++ b/src/PVE/Storage.pm
> @@ -24,6 +24,9 @@ use PVE::RPCEnvironment;
> use PVE::SSHInfo;
> use PVE::RESTEnvironment qw(log_warn);
>
> +# registers Mapping Plugins
> +use PVE::Storage::Mapping;
> +
> use PVE::Storage::Plugin;
> use PVE::Storage::DirPlugin;
> use PVE::Storage::LVMPlugin;
> diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
> index a67dc25..c5861da 100644
> --- a/src/PVE/Storage/Makefile
> +++ b/src/PVE/Storage/Makefile
> @@ -14,10 +14,12 @@ SOURCES= \
> PBSPlugin.pm \
> BTRFSPlugin.pm \
> LvmThinPlugin.pm \
> - ESXiPlugin.pm
> + ESXiPlugin.pm \
> + Mapping.pm
>
> .PHONY: install
> install:
> make -C Common install
> for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Storage/$$i; done
> make -C LunCmd install
> + make -C Mapping install
> diff --git a/src/PVE/Storage/Mapping.pm b/src/PVE/Storage/Mapping.pm
> new file mode 100644
> index 0000000..b607156
> --- /dev/null
> +++ b/src/PVE/Storage/Mapping.pm
> @@ -0,0 +1,44 @@
> +package PVE::Storage::Mapping;
> +
> +use PVE::JSONSchema;
> +
> +use PVE::Storage::Mapping::ISCSI;
This is a leftover of splitting up the previous big commit into multiple smaller ones.
This is only introduced in the next patch actually. I'll clean that up in a v3.
For testing the series it should not matter.
> +use PVE::Storage::Mapping::Plugin;
> +
> +PVE::Storage::Mapping::ISCSI->register();
same here
> +PVE::Storage::Mapping::Plugin->init(property_isolation => 1);
> +
> +sub find_mapping_on_current_node {
> + my ($id) = @_;
> +
> + my $cfg = PVE::Storage::Mapping::Plugin::config();
> + my $nodename = PVE::INotify::nodename();
> +
> + return get_node_mapping($cfg, $id, $nodename);
> +}
> +
> +sub get_node_mapping {
> + my ($cfg, $id, $nodename) = @_;
> +
> + my $mapping = $cfg->{ids}->{$id};
> + return undef if !defined($mapping);
> +
> + my $plugin_type = $cfg->{ids}->{$id}->{type};
> + my $plugin = PVE::Storage::Mapping::Plugin->lookup($plugin_type);
> +
> + my $map_key = $plugin->get_map_key();
> + my $map_fmt = $plugin->get_map_format();
> + warn "no '$map_key' property found\n" if !$map_fmt;
> +
> + my $res = [];
> + for my $map ($mapping->{$map_key}->@*) {
> + my $entry = eval { PVE::JSONSchema::parse_property_string($map_fmt, $map) };
> + warn $@ if $@;
> + if ($entry && $entry->{node} eq $nodename) {
> + push $res->@*, $entry;
> + }
> + }
> + return $res;
> +}
> +
> +1;
> diff --git a/src/PVE/Storage/Mapping/Makefile b/src/PVE/Storage/Mapping/Makefile
> new file mode 100644
> index 0000000..168bea6
> --- /dev/null
> +++ b/src/PVE/Storage/Mapping/Makefile
> @@ -0,0 +1,6 @@
> +SOURCES= \
> + Plugin.pm
> +
> +.PHONY: install
> +install:
> + for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Storage/Mapping/$$i; done
> diff --git a/src/PVE/Storage/Mapping/Plugin.pm b/src/PVE/Storage/Mapping/Plugin.pm
> new file mode 100644
> index 0000000..2da2e26
> --- /dev/null
> +++ b/src/PVE/Storage/Mapping/Plugin.pm
> @@ -0,0 +1,74 @@
> +package PVE::Storage::Mapping::Plugin;
> +
> +use strict;
> +use warnings;
> +
> +use PVE::Storage::Mapping::ISCSI;
> +use PVE::INotify;
> +use PVE::JSONSchema;
> +use PVE::Cluster qw(
> + cfs_lock_file
> + cfs_read_file
> + cfs_register_file
> + cfs_write_file
> +);
> +
> +use base qw(PVE::SectionConfig);
> +
> +my $FILENAME = 'mapping/storage.cfg';
> +
> +cfs_register_file(
> + $FILENAME,
> + sub { __PACKAGE__->parse_config(@_); },
> + sub { __PACKAGE__->write_config(@_); },
> +);
> +
> +# from PVE::Storage::Plugin
> +sub parse_section_header {
> + my ($class, $line) = @_;
> +
> + if ($line =~ m/^(\S+):\s*(\S+)\s*$/) {
> + my ($type, $storeid) = (lc($1), $2);
> + my $errmsg = undef; # set if you want to skip whole section
> + eval { PVE::JSONSchema::parse_storage_id($storeid); };
> + $errmsg = $@ if $@;
> + my $config = {}; # to return additional attributes
> + return ($type, $storeid, $errmsg, $config);
> + }
> + return undef;
> +}
> +
> +my $defaultData = {
> + propertyList => {
> + type => { description => "Storage type." },
> + id => {
> + description => "The ID of the logical storage mapping.",
> + type => 'string',
> + format => 'pve-storage-id',
> + },
> + description => {
> + description => "Description of the logical storage.",
> + type => 'string',
> + optional => 1,
> + maxLength => 4096,
> + },
> + },
> +};
> +
> +sub private {
> + return $defaultData;
> +}
> +
> +sub config {
> + return cfs_read_file($FILENAME);
> +}
> +
> +sub get_map_key {
> + return 'map';
> +}
> +
> +sub get_map_format {
> + die "implement in subclass\n";
> +}
> +
> +1;
> diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
> index afd3141..61cda22 100644
> --- a/src/PVE/Storage/Plugin.pm
> +++ b/src/PVE/Storage/Plugin.pm
> @@ -247,6 +247,12 @@ my $defaultData = {
> default => 0,
> optional => 1,
> },
> + mapping => {
> + description => "Logical per-node storage mapping.",
> + type => 'string',
> + format => 'pve-storage-id',
> + optional => 1,
> + },
> },
> };
>
^ permalink raw reply [flat|nested] 18+ messages in thread
* [PATCH v2 storage 03/15] mapping: add iSCSI plugin
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
2026-04-30 17:26 ` [PATCH v2 cluster 01/15] mapping: add storage.cfg Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 02/15] mapping: add base plugin Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 04/15] iscsi: introduce mapping support Mira Limbeck
` (11 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
map needs to be optional, otherwise removing a map temporarily, but keeping
the mapping is not allowed
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/Mapping/ISCSI.pm | 52 ++++++++++++++++++++++++++++++++
src/PVE/Storage/Mapping/Makefile | 3 +-
2 files changed, 54 insertions(+), 1 deletion(-)
create mode 100644 src/PVE/Storage/Mapping/ISCSI.pm
diff --git a/src/PVE/Storage/Mapping/ISCSI.pm b/src/PVE/Storage/Mapping/ISCSI.pm
new file mode 100644
index 0000000..4723048
--- /dev/null
+++ b/src/PVE/Storage/Mapping/ISCSI.pm
@@ -0,0 +1,52 @@
+package PVE::Storage::Mapping::ISCSI;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::Storage::Mapping::Plugin;
+use base qw(PVE::Storage::Mapping::Plugin);
+
+sub type {
+ return 'iscsi';
+}
+
+my $map_fmt = {
+ node => get_standard_option('pve-node'),
+ target => {
+ type => 'string',
+ description => 'Local iSCSI target.',
+ },
+ portals => {
+ type => 'string',
+ description => 'List of iSCSI portals for the target.',
+ format => 'pve-storage-portal-dns-list',
+ },
+};
+
+sub properties {
+ return {
+ map => {
+ type => 'array',
+ description => 'A list of maps.',
+ optional => 1,
+ items => {
+ type => 'string',
+ format => $map_fmt,
+ },
+ },
+ };
+}
+
+sub options {
+ return {
+ description => { optional => 1 },
+ map => { optional => 1 },
+ };
+}
+
+sub get_map_format {
+ return { $map_fmt->%* };
+}
+
+1;
diff --git a/src/PVE/Storage/Mapping/Makefile b/src/PVE/Storage/Mapping/Makefile
index 168bea6..25fae16 100644
--- a/src/PVE/Storage/Mapping/Makefile
+++ b/src/PVE/Storage/Mapping/Makefile
@@ -1,5 +1,6 @@
SOURCES= \
- Plugin.pm
+ Plugin.pm \
+ ISCSI.pm
.PHONY: install
install:
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 04/15] iscsi: introduce mapping support
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (2 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 03/15] mapping: add iSCSI plugin Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 05/15] iscsi: add helper to get local config Mira Limbeck
` (10 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
`portal` and `target` should be optional then. There is no need for
those when a mapping is specified. In return the config needs to be
checked to make sure either portal and target are defined, or a mapping.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/ISCSIPlugin.pm | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 30f4178..88a35be 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -9,6 +9,7 @@ use IO::File;
use PVE::JSONSchema qw(get_standard_option);
use PVE::Storage::Plugin;
+use PVE::Storage::Mapping;
use PVE::Tools
qw(run_command file_read_firstline trim dir_glob_regex dir_glob_foreach $IPV4RE $IPV6RE);
@@ -354,8 +355,9 @@ sub properties {
sub options {
return {
- portal => { fixed => 1 },
- target => { fixed => 1 },
+ portal => { fixed => 1, optional => 1 },
+ target => { fixed => 1, optional => 1 },
+ mapping => { optional => 1 },
nodes => { optional => 1 },
disable => { optional => 1 },
content => { optional => 1 },
@@ -703,4 +705,16 @@ sub volume_import {
die "volume import is not possible on iscsi storage\n";
}
+sub check_config {
+ my ($class, $sectionId, $config, $create, $skipSchemaCheck) = @_;
+
+ my $checked = $class->SUPER::check_config($sectionId, $config, $create, $skipSchemaCheck);
+
+ # check if either target or mapping is set
+ die "iscsi storage '$sectionId' has neither 'target' nor 'mapping' defined\n"
+ if !defined($checked->{target}) && !defined($checked->{mapping});
+
+ return $checked;
+}
+
1;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 05/15] iscsi: add helper to get local config
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (3 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 04/15] iscsi: introduce mapping support Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 06/15] iscsi: change functions to handle mappings Mira Limbeck
` (9 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
local config contains either the targets and portals configured via
mapping for the local node or the ones configured via regular iSCSI
storage configuration
to help differentiate between a mapping and a simple iSCSI storage
configuration, is_mapping is set in the local config
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/ISCSIPlugin.pm | 43 ++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 88a35be..8e5cbfb 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -36,6 +36,49 @@ my sub assert_iscsi_support {
# Example: 192.168.122.252:3260,1 iqn.2003-01.org.linux-iscsi.proxmox-nfs.x8664:sn.00567885ba8f
my $ISCSI_TARGET_RE = qr/^(\S+:\d+)\,\S+\s+(\S+)\s*$/;
+my $get_local_config = sub {
+ my ($scfg) = @_;
+
+ die "neither 'target' nor 'mapping' defined\n"
+ if !defined($scfg->{target}) && !defined($scfg->{mapping});
+
+ my $res = {};
+ if ($scfg->{mapping}) {
+ $res->{is_mapping} = 1;
+
+ my $local_mappings =
+ PVE::Storage::Mapping::find_mapping_on_current_node($scfg->{mapping});
+ die "no iSCSI per-node entries found for mapping '$scfg->{mapping}'\n"
+ if !$local_mappings->@*;
+
+ for my $mapping ($local_mappings->@*) {
+ $res->{targets}->{ $mapping->{target} } = []
+ if !defined($res->{targets}->{ $mapping->{target} });
+ my $portals = [PVE::Tools::split_list($mapping->{portals})];
+
+ my $add_port = sub {
+ my ($val) = @_;
+
+ my ($ip, $port) = PVE::Tools::parse_host_and_port($val);
+ if (defined($port)) {
+ return $val;
+ } else {
+ # add default port
+ return $ip . ':3260';
+ }
+ };
+ $portals->@* = map { $add_port->($_) } $portals->@*;
+
+ push $res->{targets}->{ $mapping->{target} }->@*, $portals->@*;
+ }
+ return $res;
+ } else {
+ $res->{is_mapping} = 0;
+ $res->{targets}->{ $scfg->{target} } = iscsi_portals($scfg->{target}, $scfg->{portal});
+ return $res;
+ }
+};
+
sub iscsi_session_list {
assert_iscsi_support();
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 06/15] iscsi: change functions to handle mappings
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (4 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 05/15] iscsi: add helper to get local config Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 07/15] iscsi: introduce helper to update discovery db Mira Limbeck
` (8 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
change functions to use the new local config that unifies mapping and
non-mapping configs into a similar structure
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/ISCSIPlugin.pm | 105 ++++++++++++++++++++++-----------
1 file changed, 69 insertions(+), 36 deletions(-)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 8e5cbfb..8f2bdb5 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -479,26 +479,30 @@ sub list_images {
# we have no owner for iscsi devices
- my $target = $scfg->{target};
+ my $local_cfg = $get_local_config->($scfg);
+ my $targets = $local_cfg->{targets};
- if (my $dat = $cache->{iscsi_devices}->{$target}) {
+ for my $target (keys $targets->%*) {
+ if (my $dat = $cache->{iscsi_devices}->{$target}) {
- foreach my $volname (keys %$dat) {
+ foreach my $volname (keys %$dat) {
- my $volid = "$storeid:$volname";
+ my $volid = "$storeid:$volname";
- if ($vollist) {
- my $found = grep { $_ eq $volid } @$vollist;
- next if !$found;
- } else {
- # we have no owner for iscsi devices
- next if defined($vmid);
- }
+ if ($vollist) {
+ my $found = grep { $_ eq $volid } @$vollist;
+ next if !$found;
+ } else {
+ # we have no owner for iscsi devices
+ next if defined($vmid);
+ }
- my $info = $dat->{$volname};
- $info->{volid} = $volid;
+ my $info = $dat->{$volname};
+ $info->{volid} = $volid;
- push @$res, $info;
+ push @$res, $info;
+ }
+ last;
}
}
@@ -514,8 +518,13 @@ sub iscsi_session {
sub status {
my ($class, $storeid, $scfg, $cache) = @_;
- my $session = iscsi_session($cache, $scfg->{target});
- my $active = defined($session) ? 1 : 0;
+ my $local_cfg = $get_local_config->($scfg);
+ my $active = 0;
+ for my $target (keys $local_cfg->{targets}->%*) {
+ my $session = iscsi_session($cache, $target);
+ $active = 1 if defined($session);
+ last if defined($session);
+ }
return (0, 0, 0, $active);
}
@@ -525,28 +534,41 @@ sub activate_storage {
return if !assert_iscsi_support(1);
- my $sessions = iscsi_session($cache, $scfg->{target});
- my $portals = iscsi_portals($scfg->{target}, $scfg->{portal});
- my $do_login = !defined($sessions);
+ my $local_cfg = $get_local_config->($scfg);
+ my $targets = {};
+ for my $target (keys $local_cfg->{targets}->%*) {
+ my $sessions = iscsi_session($cache, $target);
+ $targets->{$target}->{sessions} = $sessions;
+ $targets->{$target}->{portals} = $local_cfg->{targets}->{$target};
+ }
+ my $do_login = 0;
+ for my $target (keys $targets->%*) {
+ $do_login = 1 if !defined($targets->{$target}->{sessions});
+ }
if (!$do_login) {
# We should check that sessions for all portals are available
- my $session_portals = [map { $_->{portal} } (@$sessions)];
-
- for my $portal (@$portals) {
- if (!grep(/^\Q$portal\E$/, @$session_portals)) {
- $do_login = 1;
- last;
+ for my $target (keys $targets->%*) {
+ my $session_portals = [map { $_->{portal} } ($targets->{$target}->{sessions}->@*)];
+ for my $portal ($targets->{$target}->{portals}->@*) {
+ if (!grep(/^\Q$portal\E(?::3260)?$/, $session_portals->@*)) {
+ $do_login = 1;
+ last;
+ }
}
}
}
if ($do_login) {
- eval { iscsi_login($scfg->{target}, $portals, $cache); };
- warn $@ if $@;
+ for my $target (keys $targets->%*) {
+ eval { iscsi_login($target, $targets->{$target}->{portals}, $cache); };
+ warn $@ if $@;
+ }
} else {
# make sure we get all devices
- iscsi_session_rescan($sessions);
+ for my $target (keys $targets->%*) {
+ iscsi_session_rescan($targets->{$target}->{sessions});
+ }
}
}
@@ -555,8 +577,11 @@ sub deactivate_storage {
return if !assert_iscsi_support(1);
- if (defined(iscsi_session($cache, $scfg->{target}))) {
- iscsi_logout($scfg->{target});
+ my $local_cfg = $get_local_config->($scfg);
+ for my $target (keys $local_cfg->{targets}->%*) {
+ if (defined(iscsi_session($cache, $target))) {
+ iscsi_logout($target);
+ }
}
}
@@ -654,18 +679,26 @@ sub activate_volume {
my $device_path = $udev_query_path->($real_path);
my $resolved_paths = $resolve_virtual_devices->($device_path);
- my $found = $check_devices_part_of_target->($resolved_paths, $scfg->{target});
- die "volume '$volname' not part of target '$scfg->{target}'\n" if !$found;
+ my $local_cfg = $get_local_config->($scfg);
+ my $found = 0;
+ for my $target ($local_cfg->{targets}->%*) {
+ $found ||= $check_devices_part_of_target->($resolved_paths, $target);
+ last if $found;
+ }
+ die "volume '$volname' not part of any matching target\n" if !$found;
}
sub check_connection {
my ($class, $storeid, $scfg) = @_;
+
my $cache = {};
- my $portals = iscsi_portals($scfg->{target}, $scfg->{portal});
+ my $local_cfg = $get_local_config->($scfg);
- for my $portal (@$portals) {
- my $result = iscsi_test_portal($scfg->{target}, $portal, $cache);
- return $result if $result;
+ for my $target (keys $local_cfg->{targets}->%*) {
+ for my $portal ($local_cfg->{targets}->{$target}->@*) {
+ my $result = iscsi_test_portal($target, $portal, $cache);
+ return $result if $result;
+ }
}
return 0;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 07/15] iscsi: introduce helper to update discovery db
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (5 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 06/15] iscsi: change functions to handle mappings Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 08/15] iscsi: rework to update discovery db and simplify login Mira Limbeck
` (7 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
In the case of using mappings, running a discovery against the
configured portals could lead to lots of additional node entries, as can
be seen with the original iSCSI plugin way.
Add a discovery db update helper that adds and removes node entries
based on what is configured via mappings.
For the non-mapping case a discovery is done. If no portal entries are
returned, only the portal from the storage config is kept for the
target.
Also adds a helper to gather all node entries.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/ISCSIPlugin.pm | 164 +++++++++++++++++++++++++++++++++
1 file changed, 164 insertions(+)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 8f2bdb5..0f77f3f 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -79,6 +79,36 @@ my $get_local_config = sub {
}
};
+sub iscsi_node_list {
+ assert_iscsi_support();
+
+ my $cmd = [$ISCSIADM, '--mode', 'node'];
+
+ my $res = {};
+ eval {
+ run_command(
+ $cmd,
+ errmsg => 'iscsi node scan failed',
+ outfunc => sub {
+ my $line = shift;
+
+ # example: 10.67.1.144:3260,4294967295 iqn.2003-01.org.linux-iscsi.iscsi.x8664:sn.81bb080df375
+ if ($line =~ m/^$ISCSI_TARGET_RE$/) {
+ my ($portal, $target) = ($1, $2);
+
+ push $res->{$target}->@*, $portal;
+ }
+ },
+ );
+ };
+
+ if (my $err = $@) {
+ die $err if $err !~ m/: No records found$/i;
+ }
+
+ return $res;
+}
+
sub iscsi_session_list {
assert_iscsi_support();
@@ -368,6 +398,140 @@ sub iscsi_device_list {
return $res;
}
+sub update_iscsi_discovery_db {
+ my ($local_cfg, $cache) = @_;
+
+ if ($local_cfg->{is_mapping}) {
+ # do mapping specific discoverydb update
+
+ my $host_targets = iscsi_node_list();
+ my $mapped_targets = $local_cfg->{targets};
+ my $added = {};
+ my $removed = {};
+
+ for my $host_target (keys $host_targets->%*) {
+ if (!defined($mapped_targets->{$host_target})) {
+ # add all portals of that target to be removed
+ $removed->{$host_target} = $host_targets->{$host_target};
+ } else {
+ for my $host_target_portal ($host_targets->{$host_target}->@*) {
+ if (!grep(/^\Q$host_target_portal\E$/, $mapped_targets->{$host_target}->@*)) {
+ push $removed->{$host_target}->@*, $host_target_portal;
+ }
+ }
+ }
+ }
+
+ for my $mapped_target (keys $mapped_targets->%*) {
+ if (!defined($host_targets->{$mapped_target})) {
+ # add all portals of that target to be added
+ $added->{$mapped_target} = $mapped_targets->{$mapped_target};
+ } else {
+ for my $mapped_target_portal ($mapped_targets->{$mapped_target}->@*) {
+ if (
+ !grep(/^\Q$mapped_target_portal\E$/,
+ $host_targets->{$mapped_target}->@*)
+ ) {
+ push $added->{$mapped_target}->@*, $mapped_target_portal;
+ }
+ }
+ }
+ }
+
+ # remove stale entries
+ for my $target (keys $removed->%*) {
+ my $target_sessions = iscsi_session($cache, $target);
+ my $sessions = {};
+ for my $target_session ($target_sessions->@*) {
+ $sessions->{ $target_session->{portal} } = $target_session->{session_id};
+ }
+
+ my $removed_sessions = [];
+ for my $portal ($removed->{$target}->@*) {
+ # log out of session before removing the stale node entry
+ # iscsiadm returns an error otherwise
+ print "removing stale iscsi session: $target via $portal\n";
+ if (defined($sessions->{$portal})) {
+ my $cmd = [
+ $ISCSIADM, '--mode', 'session', '--sid', $sessions->{$portal},
+ '--logout',
+ ];
+ eval { run_command($cmd); };
+ warn "failed to log out of stale session: $@\n" if $@;
+
+ # remove logged out sessions
+ # the list of still active sessions will be used to update cache later
+ delete $sessions->{$portal};
+ }
+ print "removing stale iscsi target entry: $target via $portal\n";
+ my $cmd = [
+ $ISCSIADM,
+ '--mode',
+ 'node',
+ '--target',
+ $target,
+ '--portal',
+ $portal,
+ '-o',
+ 'delete',
+ ];
+ eval { run_command($cmd); };
+ warn "failed to remove stale node entry: $@\n" if $@;
+ }
+ # update cache with list of active sessions
+ $cache->{iscsi_sessions}->{$target} =
+ [map { { portal => $_, session_id => $sessions->{$_} } } keys $sessions->%*];
+ }
+
+ # add new mapping entries
+ for my $target (keys $added->%*) {
+ for my $portal ($added->{$target}->@*) {
+ print "adding new iscsi target entry: $target via $portal\n";
+ my $cmd = [
+ $ISCSIADM,
+ '--mode',
+ 'node',
+ '--target',
+ $target,
+ '--portal',
+ $portal,
+ '-o',
+ 'new',
+ ];
+ eval { run_command($cmd); };
+ warn "failed to add new node entry: $@\n" if $@;
+ }
+ }
+
+ } else {
+ my $portals = undef;
+ my $target = undef;
+ for my $config_target (keys $local_cfg->{targets}->%*) {
+ $target = $config_target;
+ $portals = iscsi_portals($config_target, $local_cfg->{targets}->{$config_target}->[0]);
+ last;
+ }
+ die "no target found for discovery\n" if !defined($target);
+ die "no portals found for discovery\n" if !defined($portals);
+
+ my $res = eval { iscsi_discovery($target, $portals, $cache); };
+ warn $@ if $@;
+
+ if (defined($res->{$target})) {
+ $local_cfg->{targets}->{$target} = [map { $_->{portal} } $res->{$target}->@*];
+ } else {
+ # no portal is discovered, keep already configured node entries
+ # in that case, otherwise we might remove sessions still in use
+ #
+ # cleanup in that case should be done manually after verifying that
+ # active sessions are no longer needed
+ $local_cfg->{targets}->{$target} = $portals;
+ }
+ }
+
+ return 1;
+}
+
# Configuration
sub type {
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 08/15] iscsi: rework to update discovery db and simplify login
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (6 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 07/15] iscsi: introduce helper to update discovery db Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 09/15] iscsi: remove stale sessions in non-mapping case Mira Limbeck
` (6 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
Manually updating the discovery db, and running a discovery for the
non-mapping case, simplifies the tracking of sessions and node entries
to decide if a login is necessary to a target.
Logins are done on a node+target level, rather than on a portal
level. This makes a single iscsiadm call per target instead of per
portal.
One downside is that warnings are logged if some sessions already exist,
but not for all portals of a target.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/ISCSIPlugin.pm | 48 ++++++++++++++++++++--------------
1 file changed, 29 insertions(+), 19 deletions(-)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 0f77f3f..b3f4e60 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -74,7 +74,7 @@ my $get_local_config = sub {
return $res;
} else {
$res->{is_mapping} = 0;
- $res->{targets}->{ $scfg->{target} } = iscsi_portals($scfg->{target}, $scfg->{portal});
+ $res->{targets}->{ $scfg->{target} } = [$scfg->{portal}];
return $res;
}
};
@@ -237,9 +237,6 @@ sub iscsi_login {
assert_iscsi_support();
- eval { iscsi_discovery($target, $portals, $cache); };
- warn $@ if $@;
-
# Disable retries to avoid blocking pvestatd for too long, next iteration will retry anyway
eval {
my $cmd = [
@@ -259,7 +256,14 @@ sub iscsi_login {
};
warn $@ if $@;
- run_command([$ISCSIADM, '--mode', 'node', '--targetname', $target, '--login']);
+ # iscsiadm warns if some target/portals are already logged in
+ # a workaround would be to check each of them and only log in on those
+ # that have no session yet, rather than just doing a log in per node
+ my $cmd = [
+ $ISCSIADM, '--mode', 'node', '--targetname', $target, '--login',
+ ];
+ eval { run_command($cmd); };
+ warn $@ if $@;
}
sub iscsi_logout {
@@ -699,32 +703,30 @@ sub activate_storage {
return if !assert_iscsi_support(1);
my $local_cfg = $get_local_config->($scfg);
+
+ update_iscsi_discovery_db($local_cfg, $cache);
+
my $targets = {};
for my $target (keys $local_cfg->{targets}->%*) {
my $sessions = iscsi_session($cache, $target);
$targets->{$target}->{sessions} = $sessions;
$targets->{$target}->{portals} = $local_cfg->{targets}->{$target};
}
+
my $do_login = 0;
+ my $login_targets = {};
for my $target (keys $targets->%*) {
- $do_login = 1 if !defined($targets->{$target}->{sessions});
- }
-
- if (!$do_login) {
- # We should check that sessions for all portals are available
- for my $target (keys $targets->%*) {
- my $session_portals = [map { $_->{portal} } ($targets->{$target}->{sessions}->@*)];
- for my $portal ($targets->{$target}->{portals}->@*) {
- if (!grep(/^\Q$portal\E(?::3260)?$/, $session_portals->@*)) {
- $do_login = 1;
- last;
- }
+ my $sessions = [map { $_->{portal} } ($targets->{$target}->{sessions}->@*)];
+ for my $portal ($targets->{$target}->{portals}->@*) {
+ if (!grep(/\Q$portal\E$/, $sessions->@*)) {
+ push $login_targets->{$target}->@*, $portal;
}
}
}
+ $do_login = keys $login_targets->%*;
if ($do_login) {
- for my $target (keys $targets->%*) {
+ for my $target (keys $login_targets->%*) {
eval { iscsi_login($target, $targets->{$target}->{portals}, $cache); };
warn $@ if $@;
}
@@ -857,9 +859,17 @@ sub check_connection {
my $cache = {};
my $local_cfg = $get_local_config->($scfg);
+ my $is_mapping = $local_cfg->{is_mapping};
for my $target (keys $local_cfg->{targets}->%*) {
- for my $portal ($local_cfg->{targets}->{$target}->@*) {
+ my $portals;
+ if ($is_mapping) {
+ $portals = $local_cfg->{targets}->{$target};
+ } else {
+ $portals = iscsi_portals($target, $local_cfg->{targets}->{$target}->[0]);
+ }
+
+ for my $portal ($portals->@*) {
my $result = iscsi_test_portal($target, $portal, $cache);
return $result if $result;
}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 09/15] iscsi: remove stale sessions in non-mapping case
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (7 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 08/15] iscsi: rework to update discovery db and simplify login Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 10/15] api: add mapping support Mira Limbeck
` (5 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
So far there was no cleanup for sessions of target/portal combinations
that are no longer exported.
Adds a cleanup after all logins are done or sessions are rescanned.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
This patch is optional and may be a bit controversial since it changes
the current behavior.
This patch should be discussed in-depth before it is applied.
src/PVE/Storage/ISCSIPlugin.pm | 35 ++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index b3f4e60..5b636a8 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -736,6 +736,41 @@ sub activate_storage {
iscsi_session_rescan($targets->{$target}->{sessions});
}
}
+
+ # log out of stale sessions
+ # should only be needed for the non-mapping case
+ if (!$local_cfg->{is_mapping}) {
+ for my $target (keys $targets->%*) {
+ for my $session ($targets->{$target}->{sessions}->@*) {
+ my $portals = $targets->{$target}->{portals};
+ if (!grep(/^\Q$session->{portal}\E$/, $portals->@*)) {
+ my $sid = $session->{session_id};
+ my $portal = $session->{portal};
+ print "logging out of stale iscsi session: $sid for $target via $portal\n";
+ my $cmd = [
+ $ISCSIADM, '--mode', 'session', '--sid', $sid, '--logout',
+ ];
+ eval { run_command($cmd); };
+ warn "failed to log out of stale session: $@\n" if $@;
+
+ print "removing stale node entry: $target via $portal\n";
+ $cmd = [
+ $ISCSIADM,
+ '--mode',
+ 'node',
+ '--target',
+ $target,
+ '--portal',
+ $portal,
+ '-o',
+ 'delete',
+ ];
+ eval { run_command($cmd); };
+ warn "failed to remove stale node entry: $@\n" if $@;
+ }
+ }
+ }
+ }
}
sub deactivate_storage {
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 10/15] api: add mapping support
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (8 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 09/15] iscsi: remove stale sessions in non-mapping case Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 11/15] mapping: iscsi: add discovery-portal config option Mira Limbeck
` (4 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
introduces the necessary API to create, update and delete storage
mappings
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
v2:
- now generic over mapping plugin
src/PVE/API2/Storage/Makefile | 2 +-
src/PVE/API2/Storage/Mapping.pm | 213 ++++++++++++++++++++++++++++++
src/PVE/Storage/Mapping/Plugin.pm | 14 ++
3 files changed, 228 insertions(+), 1 deletion(-)
create mode 100644 src/PVE/API2/Storage/Mapping.pm
diff --git a/src/PVE/API2/Storage/Makefile b/src/PVE/API2/Storage/Makefile
index 1705080..3792cec 100644
--- a/src/PVE/API2/Storage/Makefile
+++ b/src/PVE/API2/Storage/Makefile
@@ -1,5 +1,5 @@
-SOURCES= Content.pm Status.pm Config.pm PruneBackups.pm Scan.pm FileRestore.pm
+SOURCES= Content.pm Status.pm Config.pm PruneBackups.pm Scan.pm FileRestore.pm Mapping.pm
.PHONY: install
install:
diff --git a/src/PVE/API2/Storage/Mapping.pm b/src/PVE/API2/Storage/Mapping.pm
new file mode 100644
index 0000000..91483cd
--- /dev/null
+++ b/src/PVE/API2/Storage/Mapping.pm
@@ -0,0 +1,213 @@
+package PVE::API2::Storage::Mapping;
+
+use strict;
+use warnings;
+
+use PVE::RESTHandler;
+use PVE::Tools qw(extract_param);
+
+use base qw(PVE::RESTHandler);
+
+my $storage_mapping_type_enum = PVE::Storage::Mapping::Plugin->lookup_types();
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => 'List all storage mappings.',
+ permissions => {
+ user => 'all',
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ type => {
+ description => "List only storage of specific type.",
+ type => 'string',
+ enum => $storage_mapping_type_enum,
+ optional => 1,
+ },
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => 'object',
+ properties => {
+ id => {
+ type => 'string',
+ format => 'pve-storage-id',
+ },
+ type => {
+ type => 'string',
+ },
+ map => {
+ type => 'string',
+ },
+ },
+ },
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ my $authuser = $rpcenv->get_user();
+
+ my $cfg = PVE::Storage::Mapping::Plugin::config();
+
+ my $mapping_privs = ['Mapping.Audit', 'Mapping.Modify', 'Mapping.Use'];
+
+ my $res = [];
+ foreach my $id (keys $cfg->{ids}->%*) {
+ my $type = $cfg->{ids}->{$id}->{type};
+
+ next if defined($param->{type}) && $type ne $param->{type};
+ next
+ if !$rpcenv->check_any($authuser, "/mapping/storage/$type/$id", $mapping_privs, 1);
+
+ my $map = $cfg->{ids}->{$id}->{map};
+
+ push @$res,
+ {
+ id => $id,
+ type => $type,
+ map => $map,
+ };
+ }
+
+ return $res;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create',
+ path => '',
+ method => 'POST',
+ description => 'Create a new storage mapping.',
+ permissions => {
+ check => ['perm', '/mapping/storage/', ['Mapping.Modify']],
+ },
+ parameters => PVE::Storage::Mapping::Plugin->createSchema(),
+ returns => {
+ type => 'null',
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ my $type = extract_param($param, 'type');
+
+ my $plugin = PVE::Storage::Mapping::Plugin->lookup($type);
+ my $opts = $plugin->check_config($id, $param, 1, 1);
+
+ PVE::Storage::Mapping::Plugin::lock_storage_mapping_config(
+ sub {
+ my $cfg = PVE::Storage::Mapping::Plugin::config();
+
+ die "storage mapping ID '$id' already defined\n" if defined($cfg->{ids}->{$id});
+
+ $cfg->{ids}->{$id} = $opts;
+
+ PVE::Storage::Mapping::Plugin::write_storage_mapping_config($cfg);
+ },
+ "create storage mapping failed",
+ );
+
+ return;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'update',
+ path => '{id}',
+ method => 'PUT',
+ description => 'Update a storage mapping.',
+ permissions => {
+ check => ['perm', '/mapping/storage/{id}', ['Mapping.Modify']],
+ },
+ parameters => PVE::Storage::Mapping::Plugin->updateSchema(),
+ returns => {
+ type => 'null',
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $digest = extract_param($param, 'digest');
+ my $delete = extract_param($param, 'delete');
+ my $id = extract_param($param, 'id');
+ my $type = extract_param($param, 'type');
+
+ if ($delete) {
+ $delete = [PVE::Tools::split_list($delete)];
+ }
+
+ my $plugin = PVE::Storage::Mapping::Plugin->lookup($type);
+ my $opts = $plugin->check_config($id, $param, 1, 1);
+
+ PVE::Storage::Mapping::Plugin::lock_storage_mapping_config(
+ sub {
+ my $cfg = PVE::Storage::Mapping::Plugin::config();
+
+ PVE::Tools::assert_if_modified($cfg->{digest}, $digest) if defined($digest);
+
+ die "storage mapping ID '$id' does not exist\n" if !defined($cfg->{ids}->{$id});
+
+ my $data = $cfg->{ids}->{$id};
+ my $options = $plugin->private()->{options}->{$type};
+ PVE::SectionConfig::delete_from_config($data, $options, $opts, $delete);
+ foreach my $key (keys $opts->%*) {
+ $data->{$key} = $opts->{$key};
+ }
+
+ PVE::Storage::Mapping::Plugin::write_storage_mapping_config($cfg);
+ },
+ "update storage mapping failed",
+ );
+
+ return;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'delete',
+ path => '{id}',
+ method => 'DELETE',
+ description => 'Remove a storage mapping.',
+ permissions => {
+ check => ['perm', '/mapping/storage', ['Mapping.Modify']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => {
+ type => 'string',
+ format => 'pve-storage-id',
+ },
+ },
+ },
+ returns => {
+ type => 'null',
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = $param->{id};
+
+ PVE::Storage::Mapping::Plugin::lock_storage_mapping_config(
+ sub {
+ my $cfg = PVE::Storage::Mapping::Plugin::config();
+
+ if ($cfg->{ids}->{$id}) {
+ delete $cfg->{ids}->{$id};
+ }
+
+ PVE::Storage::Mapping::Plugin::write_storage_mapping_config($cfg);
+ },
+ "delete storage mapping failed",
+ );
+
+ return;
+ },
+});
+
+1;
diff --git a/src/PVE/Storage/Mapping/Plugin.pm b/src/PVE/Storage/Mapping/Plugin.pm
index 2da2e26..a0d3198 100644
--- a/src/PVE/Storage/Mapping/Plugin.pm
+++ b/src/PVE/Storage/Mapping/Plugin.pm
@@ -71,4 +71,18 @@ sub get_map_format {
die "implement in subclass\n";
}
+sub lock_storage_mapping_config {
+ my ($code, $errmsg) = @_;
+
+ cfs_lock_file($FILENAME, undef, $code);
+ if (my $err = $@) {
+ $errmsg ? die "$errmsg: $err" : die $err;
+ }
+}
+
+sub write_storage_mapping_config {
+ my ($cfg) = @_;
+
+ cfs_write_file($FILENAME, $cfg);
+}
1;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 11/15] mapping: iscsi: add discovery-portal config option
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (9 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 10/15] api: add mapping support Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 12/15] iscsi: add support for non-persistent discovery Mira Limbeck
` (3 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
This is in preparation for GUI support. These portals could be used by
the GUI in the future to run discovery against.
src/PVE/Storage/Mapping/ISCSI.pm | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/PVE/Storage/Mapping/ISCSI.pm b/src/PVE/Storage/Mapping/ISCSI.pm
index 4723048..cec52a3 100644
--- a/src/PVE/Storage/Mapping/ISCSI.pm
+++ b/src/PVE/Storage/Mapping/ISCSI.pm
@@ -26,6 +26,12 @@ my $map_fmt = {
sub properties {
return {
+ 'discovery-portals' => {
+ type => 'string',
+ description => 'List of portals to use for discovery.',
+ format => 'pve-storage-portal-dns-list',
+ optional => 1,
+ },
map => {
type => 'array',
description => 'A list of maps.',
@@ -41,6 +47,7 @@ sub properties {
sub options {
return {
description => { optional => 1 },
+ 'discovery-portals' => { optional => 1 },
map => { optional => 1 },
};
}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 storage 12/15] iscsi: add support for non-persistent discovery
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (10 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 11/15] mapping: iscsi: add discovery-portal config option Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:38 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 13/15] api: add non-persistent iscsi discovery option Mira Limbeck
` (2 subsequent siblings)
14 siblings, 1 reply; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
When running a discovery to gather all targets and portals to configure
mappings it is not necessary to modify the discovery db.
For multiple discovery portals it is also important to not stop early
when a previous portal already returned targets and portals.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/Storage/ISCSIPlugin.pm | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 5b636a8..a95edf4 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -200,7 +200,7 @@ sub iscsi_portals {
}
sub iscsi_discovery {
- my ($target_in, $portals, $cache) = @_;
+ my ($target_in, $portals, $cache, $update_db) = @_;
assert_iscsi_support();
@@ -209,6 +209,7 @@ sub iscsi_discovery {
next if !iscsi_test_portal($target_in, $portal, $cache); # fixme: raise exception here?
my $cmd = [$ISCSIADM, '--mode', 'discovery', '--type', 'sendtargets', '--portal', $portal];
+ push $cmd->@*, '-o', 'nonpersistent' if !$update_db;
eval {
run_command(
$cmd,
@@ -226,7 +227,7 @@ sub iscsi_discovery {
};
# In case of multipath we can stop after receiving targets from any available portal
- last if scalar(keys %$res) > 0;
+ last if defined($target) && scalar(keys %$res) > 0;
}
return $res;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* Re: [PATCH v2 storage 12/15] iscsi: add support for non-persistent discovery
2026-04-30 17:27 ` [PATCH v2 storage 12/15] iscsi: add support for non-persistent discovery Mira Limbeck
@ 2026-04-30 17:38 ` Mira Limbeck
0 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:38 UTC (permalink / raw)
To: pve-devel
On 4/30/26 7:31 PM, Mira Limbeck wrote:
> When running a discovery to gather all targets and portals to configure
> mappings it is not necessary to modify the discovery db.
>
> For multiple discovery portals it is also important to not stop early
> when a previous portal already returned targets and portals.
>
> Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
> ---
>
> src/PVE/Storage/ISCSIPlugin.pm | 5 +++--
> 1 file changed, 3 insertions(+), 2 deletions(-)
>
> diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
> index 5b636a8..a95edf4 100644
> --- a/src/PVE/Storage/ISCSIPlugin.pm
> +++ b/src/PVE/Storage/ISCSIPlugin.pm
> @@ -200,7 +200,7 @@ sub iscsi_portals {
> }
>
> sub iscsi_discovery {
> - my ($target_in, $portals, $cache) = @_;
> + my ($target_in, $portals, $cache, $update_db) = @_;
>
> assert_iscsi_support();
>
> @@ -209,6 +209,7 @@ sub iscsi_discovery {
> next if !iscsi_test_portal($target_in, $portal, $cache); # fixme: raise exception here?
>
> my $cmd = [$ISCSIADM, '--mode', 'discovery', '--type', 'sendtargets', '--portal', $portal];
> + push $cmd->@*, '-o', 'nonpersistent' if !$update_db;
> eval {
> run_command(
> $cmd,
> @@ -226,7 +227,7 @@ sub iscsi_discovery {
> };
>
> # In case of multipath we can stop after receiving targets from any available portal
> - last if scalar(keys %$res) > 0;
> + last if defined($target) && scalar(keys %$res) > 0;
> }
>
> return $res;
This is missing a change to the iscsi_discovery call in
update_iscsi_discovery_db. There the iscsi_discovery call needs to be
changed to:
my $res = eval { iscsi_discovery($target, $portals, $cache, 1); };
adding the `1` at the end to make sure the discovery db is updated in
that case.
I'll fix that in a v3 next week.
^ permalink raw reply [flat|nested] 18+ messages in thread
* [PATCH v2 storage 13/15] api: add non-persistent iscsi discovery option
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (11 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 12/15] iscsi: add support for non-persistent discovery Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [POC v2 storage 14/15] mapping: add zfspool plugin Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 manager 15/15] api: mapping: add storage mapping path Mira Limbeck
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
and return new portals list with reachability information for each
portal
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
src/PVE/API2/Storage/Scan.pm | 32 ++++++++++++++++++++++++++++++--
src/PVE/CLI/pvesm.pm | 3 ++-
src/PVE/Storage.pm | 2 +-
src/PVE/Storage/ISCSIPlugin.pm | 15 +++++++++++----
4 files changed, 44 insertions(+), 8 deletions(-)
diff --git a/src/PVE/API2/Storage/Scan.pm b/src/PVE/API2/Storage/Scan.pm
index a58bdd8..f272e3b 100644
--- a/src/PVE/API2/Storage/Scan.pm
+++ b/src/PVE/API2/Storage/Scan.pm
@@ -265,6 +265,12 @@ __PACKAGE__->register_method({
type => 'string',
format => 'pve-storage-portal-dns',
},
+ persist => {
+ description => 'Persist discovery results to discovery db.',
+ type => 'boolean',
+ optional => 1,
+ default => 1,
+ },
},
},
returns => {
@@ -280,17 +286,39 @@ __PACKAGE__->register_method({
description => "The iSCSI portal name.",
type => 'string',
},
+ portals => {
+ description => 'The iSCSI portals discovered, includes reachability.',
+ type => 'string',
+ },
},
},
},
code => sub {
my ($param) = @_;
- my $res = PVE::Storage::scan_iscsi($param->{portal});
+ my $persist = $param->{persist} // 1;
+ my $res = PVE::Storage::scan_iscsi($param->{portal}, $persist);
my $data = [];
foreach my $k (sort keys %$res) {
- push @$data, { target => $k, portal => join(',', @{ $res->{$k} }) };
+ my $portals = [];
+ my $portals_reachability = [];
+ for my $entry ($res->{$k}->@*) {
+ my $portal = $entry->{portal};
+ my $reachability = $entry->{reachable} ? '1' : '0';
+ push $portals->@*, $portal;
+ push $portals_reachability->@*, $portal . ':' . $reachability;
+ }
+ #my $portals = [ map { $_->{portal} } $res->{$k}->@* ];
+ #my $portals_reachability = [ map { $_->{portal} . ':' . ($_->{reachable} ? '1' : '0') } $res->{$k}->@* ];
+ my $entry = {
+ target => $k,
+ portal => join(',', $portals->@*),
+ portals => join(',', $portals_reachability->@*),
+ #portals => join(',', map { $_->{portal}.':'.($_->{reachable} // '0') } $res->{$k}->@*),
+ };
+ #push @$data, { target => $k, portal => join(',', $portals->@*) };
+ push $data->@*, $entry;
}
return $data;
diff --git a/src/PVE/CLI/pvesm.pm b/src/PVE/CLI/pvesm.pm
index 06bc4c9..6e88123 100755
--- a/src/PVE/CLI/pvesm.pm
+++ b/src/PVE/CLI/pvesm.pm
@@ -688,7 +688,8 @@ our $cmddef = {
$maxlen = $len if $len > $maxlen;
}
foreach my $rec (@$res) {
- printf "%-${maxlen}s %s\n", $rec->{target}, $rec->{portal};
+ printf "%-${maxlen}s %s %s\n", $rec->{target}, $rec->{portal},
+ $rec->{portals};
}
},
],
diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index d783788..73546d2 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -1658,7 +1658,7 @@ sub scan_iscsi {
die "unable to parse/resolve portal address '${portal_in}'\n";
}
- return PVE::Storage::ISCSIPlugin::iscsi_discovery(undef, [$portal]);
+ return PVE::Storage::ISCSIPlugin::iscsi_discovery(undef, [$portal], undef, 0);
}
sub storage_default_format {
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index a95edf4..066d1ed 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -200,7 +200,7 @@ sub iscsi_portals {
}
sub iscsi_discovery {
- my ($target_in, $portals, $cache, $update_db) = @_;
+ my ($target_in, $portals, $cache, $persist) = @_;
assert_iscsi_support();
@@ -208,8 +208,9 @@ sub iscsi_discovery {
for my $portal ($portals->@*) {
next if !iscsi_test_portal($target_in, $portal, $cache); # fixme: raise exception here?
+ my $target_found = 0;
my $cmd = [$ISCSIADM, '--mode', 'discovery', '--type', 'sendtargets', '--portal', $portal];
- push $cmd->@*, '-o', 'nonpersistent' if !$update_db;
+ push $cmd->@*, '-o', 'nonpersistent' if !$persist;
eval {
run_command(
$cmd,
@@ -220,14 +221,20 @@ sub iscsi_discovery {
my ($portal, $target) = ($1, $2);
# one target can have more than one portal (multipath)
# and sendtargets should return all of them in single call
- push @{ $res->{$target} }, $portal;
+ my $entry = {
+ portal => $portal,
+ reachable => !!iscsi_test_portal($target, $portal, $cache),
+ };
+ push @{ $res->{$target} }, $entry;
+
+ $target_found = 1 if defined($target_in) && $target_in eq $target;
}
},
);
};
# In case of multipath we can stop after receiving targets from any available portal
- last if defined($target) && scalar(keys %$res) > 0;
+ last if $target_found && scalar(keys %$res) > 0;
}
return $res;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [POC v2 storage 14/15] mapping: add zfspool plugin
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (12 preceding siblings ...)
2026-04-30 17:27 ` [PATCH v2 storage 13/15] api: add non-persistent iscsi discovery option Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 manager 15/15] api: mapping: add storage mapping path Mira Limbeck
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
proof of concept to see if the chosen mapping abstraction works for
other storages as well.
zfspool mapping plugin enables replication between nodes with different
zpools.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
This is sent as a POC since the motivation behind it was mainly to see
if the mapping plugin abstraction works for other storages as well.
Please note that testing wasn't in-depth enough to make sure
all the uses are handled and work reliably with mappings.
But in my tests (mostly replication) it worked nicely.
src/PVE/Storage/Mapping.pm | 2 +
src/PVE/Storage/Mapping/Makefile | 3 +-
src/PVE/Storage/Mapping/Plugin.pm | 2 +
src/PVE/Storage/Mapping/ZFSPool.pm | 48 +++++++++++
src/PVE/Storage/ZFSPoolPlugin.pm | 133 ++++++++++++++++++++---------
5 files changed, 148 insertions(+), 40 deletions(-)
create mode 100644 src/PVE/Storage/Mapping/ZFSPool.pm
diff --git a/src/PVE/Storage/Mapping.pm b/src/PVE/Storage/Mapping.pm
index b607156..917ce32 100644
--- a/src/PVE/Storage/Mapping.pm
+++ b/src/PVE/Storage/Mapping.pm
@@ -3,9 +3,11 @@ package PVE::Storage::Mapping;
use PVE::JSONSchema;
use PVE::Storage::Mapping::ISCSI;
+use PVE::Storage::Mapping::ZFSPool;
use PVE::Storage::Mapping::Plugin;
PVE::Storage::Mapping::ISCSI->register();
+PVE::Storage::Mapping::ZFSPool->register();
PVE::Storage::Mapping::Plugin->init(property_isolation => 1);
sub find_mapping_on_current_node {
diff --git a/src/PVE/Storage/Mapping/Makefile b/src/PVE/Storage/Mapping/Makefile
index 25fae16..0d43f52 100644
--- a/src/PVE/Storage/Mapping/Makefile
+++ b/src/PVE/Storage/Mapping/Makefile
@@ -1,6 +1,7 @@
SOURCES= \
Plugin.pm \
- ISCSI.pm
+ ISCSI.pm \
+ ZFSPool.pm
.PHONY: install
install:
diff --git a/src/PVE/Storage/Mapping/Plugin.pm b/src/PVE/Storage/Mapping/Plugin.pm
index a0d3198..fe5ecd0 100644
--- a/src/PVE/Storage/Mapping/Plugin.pm
+++ b/src/PVE/Storage/Mapping/Plugin.pm
@@ -4,6 +4,8 @@ use strict;
use warnings;
use PVE::Storage::Mapping::ISCSI;
+use PVE::Storage::Mapping::ZFSPool;
+
use PVE::INotify;
use PVE::JSONSchema;
use PVE::Cluster qw(
diff --git a/src/PVE/Storage/Mapping/ZFSPool.pm b/src/PVE/Storage/Mapping/ZFSPool.pm
new file mode 100644
index 0000000..b335bb6
--- /dev/null
+++ b/src/PVE/Storage/Mapping/ZFSPool.pm
@@ -0,0 +1,48 @@
+package PVE::Storage::Mapping::ZFSPool;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::Storage::Mapping::Plugin;
+use base qw(PVE::Storage::Mapping::Plugin);
+
+sub type {
+ return 'zfspool';
+}
+
+my $map_fmt = {
+ node => get_standard_option('pve-node'),
+ pool => {
+ type => 'string',
+ description => 'Local ZFS pool.',
+ },
+};
+
+sub properties {
+ return {
+ map => {
+ type => 'array',
+ description => 'A list of maps.',
+ optional => 1,
+ items => {
+ type => 'string',
+ format => $map_fmt,
+ },
+ },
+ };
+}
+
+sub options {
+ return {
+ description => { optional => 1 },
+ map => { optional => 1 },
+ };
+}
+
+sub get_map_format {
+ return { $map_fmt->%* };
+}
+
+1;
+
diff --git a/src/PVE/Storage/ZFSPoolPlugin.pm b/src/PVE/Storage/ZFSPoolPlugin.pm
index 3b3456b..4a06876 100644
--- a/src/PVE/Storage/ZFSPoolPlugin.pm
+++ b/src/PVE/Storage/ZFSPoolPlugin.pm
@@ -46,7 +46,7 @@ sub properties {
sub options {
return {
- pool => { fixed => 1 },
+ pool => { fixed => 1, optional => 1 },
blocksize => { optional => 1 },
sparse => { optional => 1 },
nodes => { optional => 1 },
@@ -54,9 +54,38 @@ sub options {
content => { optional => 1 },
bwlimit => { optional => 1 },
mountpoint => { optional => 1 },
+ mapping => { optional => 1 },
};
}
+my $get_local_pool = sub {
+ my ($scfg) = @_;
+
+ die "neither 'pool' nor 'mapping' defined\n"
+ if !defined($scfg->{pool}) && !defined($scfg->{mapping});
+
+ my $pool = undef;
+
+ # prefer mapping over direct pool config
+ if ($scfg->{mapping}) {
+ my $local_mappings =
+ PVE::Storage::Mapping::find_mapping_on_current_node($scfg->{mapping});
+ die "no ZFSPool per-node entries found for mapping '$scfg->{mapping}'\n"
+ if !defined($local_mappings) || !$local_mappings->@*;
+
+ for my $mapping ($local_mappings->@*) {
+ die "different zfs pools configured for the same node\n"
+ if defined($pool) && $pool ne $mapping->{pool};
+
+ $pool = $mapping->{pool};
+ }
+ } else {
+ $pool = $scfg->{pool};
+ }
+
+ return $pool;
+};
+
# static zfs helper methods
sub zfs_parse_zvol_list {
@@ -124,7 +153,7 @@ sub on_add_hook {
# ignore failure, pool might currently not be imported
my $mountpoint;
eval {
- my $res = $class->zfs_get_properties($scfg, 'mountpoint', $scfg->{pool}, 1);
+ my $res = $class->zfs_get_properties($scfg, 'mountpoint', $get_local_pool->($scfg), 1);
$mountpoint = PVE::Storage::Plugin::verify_path($res, 1) if defined($res);
};
@@ -145,14 +174,15 @@ sub path {
my ($vtype, $name, $vmid) = $class->parse_volname($volname);
+ my $pool = $get_local_pool->($scfg);
my $path = '';
- my $mountpoint = $scfg->{mountpoint} // "/$scfg->{pool}";
+ my $mountpoint = $scfg->{mountpoint} // "/$pool";
if ($vtype eq "images") {
if ($name =~ m/^subvol-/ || $name =~ m/^basevol-/) {
$path = "$mountpoint/$name";
} else {
- $path = "/dev/zvol/$scfg->{pool}/$name";
+ $path = "/dev/zvol/$pool/$name";
}
$path .= "\@$snapname" if defined($snapname);
} else {
@@ -315,7 +345,7 @@ sub zfs_get_pool_stats {
my $available = 0;
my $used = 0;
- my @lines = $class->zfs_get_properties($scfg, 'available,used', $scfg->{pool});
+ my @lines = $class->zfs_get_properties($scfg, 'available,used', $get_local_pool->($scfg));
if ($lines[0] =~ /^(\d+)$/) {
$available = $1;
@@ -331,6 +361,8 @@ sub zfs_get_pool_stats {
sub zfs_create_zvol {
my ($class, $scfg, $zvol, $size) = @_;
+ my $pool = $get_local_pool->($scfg);
+
# always align size to 1M as workaround until
# https://github.com/zfsonlinux/zfs/issues/8541 is solved
my $padding = (1024 - $size % 1024) % 1024;
@@ -342,7 +374,7 @@ sub zfs_create_zvol {
push @$cmd, '-b', $scfg->{blocksize} if $scfg->{blocksize};
- push @$cmd, '-V', "${size}k", "$scfg->{pool}/$zvol";
+ push @$cmd, '-V', "${size}k", "$pool/$zvol";
$class->zfs_request($scfg, undef, @$cmd);
}
@@ -350,7 +382,8 @@ sub zfs_create_zvol {
sub zfs_create_subvol {
my ($class, $scfg, $volname, $size) = @_;
- my $dataset = "$scfg->{pool}/$volname";
+ my $pool = $get_local_pool->($scfg);
+ my $dataset = "$pool/$volname";
my $quota = $size ? "${size}k" : "none";
my $cmd =
@@ -362,11 +395,12 @@ sub zfs_create_subvol {
sub zfs_delete_zvol {
my ($class, $scfg, $zvol) = @_;
+ my $pool = $get_local_pool->($scfg);
my $err;
for (my $i = 0; $i < 6; $i++) {
- eval { $class->zfs_request($scfg, undef, 'destroy', '-r', "$scfg->{pool}/$zvol"); };
+ eval { $class->zfs_request($scfg, undef, 'destroy', '-r', "$pool/$zvol"); };
if ($err = $@) {
if ($err =~ m/dataset is busy/) {
sleep(1);
@@ -387,6 +421,8 @@ sub zfs_delete_zvol {
sub zfs_list_zvol {
my ($class, $scfg) = @_;
+ my $pool = $get_local_pool->($scfg);
+
my $text = $class->zfs_request(
$scfg,
10,
@@ -397,18 +433,18 @@ sub zfs_list_zvol {
'volume,filesystem',
'-d1',
'-Hp',
- $scfg->{pool},
+ $pool,
);
# It's still required to have zfs_parse_zvol_list filter by pool, because -d1 lists
- # $scfg->{pool} too and while unlikely, it could be named to be mistaken for a volume.
- my $zvols = zfs_parse_zvol_list($text, $scfg->{pool});
+ # $pool too and while unlikely, it could be named to be mistaken for a volume.
+ my $zvols = zfs_parse_zvol_list($text, $pool);
return {} if !$zvols;
my $list = {};
foreach my $zvol (@$zvols) {
my $name = $zvol->{name};
my $parent = $zvol->{origin};
- if ($zvol->{origin} && $zvol->{origin} =~ m/^$scfg->{pool}\/(\S+)$/) {
+ if ($zvol->{origin} && $zvol->{origin} =~ m/^$pool\/(\S+)$/) {
$parent = $1;
}
@@ -430,7 +466,8 @@ sub zfs_get_sorted_snapshot_list {
my @params = ('-H', '-r', '-t', 'snapshot', '-o', 'name', $sort_params->@*);
my $vname = ($class->parse_volname($volname))[1];
- push @params, "$scfg->{pool}\/$vname";
+ my $pool = $get_local_pool->($scfg);
+ push @params, "$pool\/$vname";
my $text = $class->zfs_request($scfg, undef, 'list', @params);
my @snapshots = split(/\n/, $text);
@@ -466,9 +503,9 @@ sub volume_size_info {
my (undef, $vname, undef, $parent, undef, undef, $format) = $class->parse_volname($volname);
+ my $pool = $get_local_pool->($scfg);
my $attr = $format eq 'subvol' ? 'refquota' : 'volsize';
- my ($size, $used) =
- $class->zfs_get_properties($scfg, "$attr,usedbydataset", "$scfg->{pool}/$vname");
+ my ($size, $used) = $class->zfs_get_properties($scfg, "$attr,usedbydataset", "$pool/$vname");
$used = ($used =~ /^(\d+)$/) ? $1 : 0;
@@ -483,7 +520,8 @@ sub volume_snapshot {
my ($class, $scfg, $storeid, $volname, $snap) = @_;
my (undef, $vname, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
- my $snapshot_name = "$scfg->{pool}/$vname\@$snap";
+ my $pool = $get_local_pool->($scfg);
+ my $snapshot_name = "$pool/$vname\@$snap";
$class->zfs_request($scfg, undef, 'snapshot', $snapshot_name);
@@ -491,7 +529,7 @@ sub volume_snapshot {
# does not track this property for snapshosts and consequently does not roll
# it back. so track this information manually.
if ($format eq 'subvol') {
- my $refquota = $class->zfs_get_properties($scfg, 'refquota', "$scfg->{pool}/$vname");
+ my $refquota = $class->zfs_get_properties($scfg, 'refquota', "$pool/$vname");
$class->zfs_request(
$scfg,
@@ -507,16 +545,18 @@ sub volume_snapshot_delete {
my ($class, $scfg, $storeid, $volname, $snap, $running) = @_;
my $vname = ($class->parse_volname($volname))[1];
+ my $pool = $get_local_pool->($scfg);
$class->deactivate_volume($storeid, $scfg, $vname, $snap, {});
- $class->zfs_request($scfg, undef, 'destroy', "$scfg->{pool}/$vname\@$snap");
+ $class->zfs_request($scfg, undef, 'destroy', "$pool/$vname\@$snap");
}
sub volume_snapshot_rollback {
my ($class, $scfg, $storeid, $volname, $snap) = @_;
my (undef, $vname, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
- my $snapshot_name = "$scfg->{pool}/$vname\@$snap";
+ my $pool = $get_local_pool->($scfg);
+ my $snapshot_name = "$pool/$vname\@$snap";
my $msg = $class->zfs_request($scfg, undef, 'rollback', $snapshot_name);
@@ -527,7 +567,7 @@ sub volume_snapshot_rollback {
if ($refquota =~ m/^\d+$/) {
$class->zfs_request(
- $scfg, undef, 'set', "refquota=${refquota}", "$scfg->{pool}/$vname",
+ $scfg, undef, 'set', "refquota=${refquota}", "$pool/$vname",
);
} elsif ($refquota ne "-") {
# refquota user property was set, but not a number -> warn
@@ -539,7 +579,7 @@ sub volume_snapshot_rollback {
# caches, they get mounted in activate volume again
# see zfs bug #10931 https://github.com/openzfs/zfs/issues/10931
if ($format eq 'subvol') {
- eval { $class->zfs_request($scfg, undef, 'unmount', "$scfg->{pool}/$vname"); };
+ eval { $class->zfs_request($scfg, undef, 'unmount', "$pool/$vname"); };
if (my $err = $@) {
die $err if $err !~ m/not currently mounted$/;
}
@@ -582,7 +622,8 @@ sub volume_snapshot_info {
my @params = ('-Hp', '-r', '-t', 'snapshot', '-o', 'name,guid,creation');
my $vname = ($class->parse_volname($volname))[1];
- push @params, "$scfg->{pool}\/$vname";
+ my $pool = $get_local_pool->($scfg);
+ push @params, "$pool\/$vname";
my $text = $class->zfs_request($scfg, undef, 'list', @params);
my @lines = split(/\n/, $text);
@@ -619,7 +660,7 @@ sub activate_storage {
my ($class, $storeid, $scfg, $cache) = @_;
# Note: $scfg->{pool} can include dataset <pool>/<dataset>
- my $dataset = $scfg->{pool};
+ my $dataset = $get_local_pool->($scfg);
my $pool = ($dataset =~ s!/.*$!!r);
return 1 if dataset_mounted_heuristic($dataset); # early return
@@ -657,13 +698,14 @@ sub activate_volume {
return 1 if defined($snapname);
my (undef, $dataset, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
+ my $pool = $get_local_pool->($scfg);
if ($format eq 'raw') {
$class->zfs_wait_for_zvol_link($scfg, $volname);
} elsif ($format eq 'subvol') {
- my $mounted = $class->zfs_get_properties($scfg, 'mounted', "$scfg->{pool}/$dataset");
+ my $mounted = $class->zfs_get_properties($scfg, 'mounted', "$pool/$dataset");
if ($mounted !~ m/^yes$/) {
- $class->zfs_request($scfg, undef, 'mount', "$scfg->{pool}/$dataset");
+ $class->zfs_request($scfg, undef, 'mount', "$pool/$dataset");
}
}
@@ -686,28 +728,25 @@ sub clone_image {
die "clone_image only works on base images\n" if !$isBase;
my $name = $class->find_free_diskname($storeid, $scfg, $vmid, $format);
+ my $pool = $get_local_pool->($scfg);
if ($format eq 'subvol') {
my $size = $class->zfs_request(
- $scfg, undef, 'list', '-Hp', '-o', 'refquota', "$scfg->{pool}/$basename",
+ $scfg, undef, 'list', '-Hp', '-o', 'refquota', "$pool/$basename",
);
chomp($size);
$class->zfs_request(
$scfg,
undef,
'clone',
- "$scfg->{pool}/$basename\@$snap",
- "$scfg->{pool}/$name",
+ "$pool/$basename\@$snap",
+ "$pool/$name",
'-o',
"refquota=$size",
);
} else {
$class->zfs_request(
- $scfg,
- undef,
- 'clone',
- "$scfg->{pool}/$basename\@$snap",
- "$scfg->{pool}/$name",
+ $scfg, undef, 'clone', "$pool/$basename\@$snap", "$pool/$name",
);
}
@@ -731,8 +770,9 @@ sub create_base {
$newname =~ s/^vm-/base-/;
}
my $newvolname = $basename ? "$basename/$newname" : "$newname";
+ my $pool = $get_local_pool->($scfg);
- $class->zfs_request($scfg, undef, 'rename', "$scfg->{pool}/$name", "$scfg->{pool}/$newname");
+ $class->zfs_request($scfg, undef, 'rename', "$pool/$name", "$pool/$newname");
my $running = undef; #fixme : is create_base always offline ?
@@ -756,7 +796,8 @@ sub volume_resize {
$new_size = $new_size + $padding;
}
- $class->zfs_request($scfg, undef, 'set', "$attr=${new_size}k", "$scfg->{pool}/$vname");
+ my $pool = $get_local_pool->($scfg);
+ $class->zfs_request($scfg, undef, 'set', "$attr=${new_size}k", "$pool/$vname");
return $new_size;
}
@@ -822,7 +863,8 @@ sub volume_export {
my $arg = $with_snapshots ? '-I' : '-i';
push @$cmd, $arg, $base_snapshot;
}
- push @$cmd, '--', "$scfg->{pool}/$dataset\@$snapshot";
+ my $pool = $get_local_pool->($scfg);
+ push @$cmd, '--', "$pool/$dataset\@$snapshot";
run_command($cmd, output => $fd);
@@ -862,8 +904,9 @@ sub volume_import {
my (undef, $dataset, $vmid, undef, undef, undef, $volume_format) =
$class->parse_volname($volname);
+ my $pool = $get_local_pool->($scfg);
- my $zfspath = "$scfg->{pool}/$dataset";
+ my $zfspath = "$pool/$dataset";
my $suffix = defined($base_snapshot) ? "\@$base_snapshot" : '';
my $exists = 0 == run_command(
['zfs', 'get', '-H', 'name', $zfspath . $suffix],
@@ -876,7 +919,7 @@ sub volume_import {
die "volume '$zfspath' already exists\n" if !$allow_rename;
warn "volume '$zfspath' already exists - importing with a different name\n";
$dataset = $class->find_free_diskname($storeid, $scfg, $vmid, $volume_format);
- $zfspath = "$scfg->{pool}/$dataset";
+ $zfspath = "$pool/$dataset";
}
eval { run_command(['zfs', 'recv', '-F', '--', $zfspath], input => "<&$fd") };
@@ -909,7 +952,7 @@ sub rename_volume {
$target_volname = $class->find_free_diskname($storeid, $scfg, $target_vmid, $format)
if !$target_volname;
- my $pool = $scfg->{pool};
+ my $pool = $get_local_pool->($scfg);
my $source_zfspath = "${pool}/${source_image}";
my $target_zfspath = "${pool}/${target_volname}";
@@ -933,4 +976,16 @@ sub rename_snapshot {
die "rename_snapshot is not supported for $class";
}
+sub check_config {
+ my ($class, $sectionId, $config, $create, $skipSchemaCheck) = @_;
+
+ my $checked = $class->SUPER::check_config($sectionId, $config, $create, $skipSchemaCheck);
+
+ # check if either target or mapping is set
+ die "zfspool storage '$sectionId' has neither 'pool' nor 'mapping' defined\n"
+ if !defined($checked->{pool}) && !defined($checked->{mapping});
+
+ return $checked;
+}
+
1;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH v2 manager 15/15] api: mapping: add storage mapping path
2026-04-30 17:26 [PATCH v2 cluster/storage/manager 00/15] storage mapping Mira Limbeck
` (13 preceding siblings ...)
2026-04-30 17:27 ` [POC v2 storage 14/15] mapping: add zfspool plugin Mira Limbeck
@ 2026-04-30 17:27 ` Mira Limbeck
14 siblings, 0 replies; 18+ messages in thread
From: Mira Limbeck @ 2026-04-30 17:27 UTC (permalink / raw)
To: pve-devel
To be in line with PCI and USB mappings, an entry in
PVE::API2::Cluster::Mapping is added. The actual logic is implemented in
pve-storage.
Signed-off-by: Mira Limbeck <m.limbeck@proxmox.com>
---
v2:
- no change
PVE/API2/Cluster/Mapping.pm | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/PVE/API2/Cluster/Mapping.pm b/PVE/API2/Cluster/Mapping.pm
index e39a16832..452eeb469 100644
--- a/PVE/API2/Cluster/Mapping.pm
+++ b/PVE/API2/Cluster/Mapping.pm
@@ -6,6 +6,7 @@ use warnings;
use PVE::API2::Cluster::Mapping::Dir;
use PVE::API2::Cluster::Mapping::PCI;
use PVE::API2::Cluster::Mapping::USB;
+use PVE::API2::Storage::Mapping;
use base qw(PVE::RESTHandler);
@@ -24,6 +25,11 @@ __PACKAGE__->register_method({
path => 'usb',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Storage::Mapping",
+ path => 'storage',
+});
+
__PACKAGE__->register_method({
name => 'index',
path => '',
@@ -47,7 +53,7 @@ __PACKAGE__->register_method({
my ($param) = @_;
my $result = [
- { name => 'dir' }, { name => 'pci' }, { name => 'usb' },
+ { name => 'dir' }, { name => 'pci' }, { name => 'usb' }, { name => 'storage' },
];
return $result;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread