public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Mira Limbeck <m.limbeck@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [POC v2 storage 14/15] mapping: add zfspool plugin
Date: Thu, 30 Apr 2026 19:27:12 +0200	[thread overview]
Message-ID: <20260430173220.441001-15-m.limbeck@proxmox.com> (raw)
In-Reply-To: <20260430173220.441001-1-m.limbeck@proxmox.com>

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




  parent reply	other threads:[~2026-04-30 17:34 UTC|newest]

Thread overview: 18+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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:35   ` Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 03/15] mapping: add iSCSI plugin Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 04/15] iscsi: introduce mapping support Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 05/15] iscsi: add helper to get local config Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 06/15] iscsi: change functions to handle mappings Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 07/15] iscsi: introduce helper to update discovery db Mira Limbeck
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 ` [PATCH v2 storage 09/15] iscsi: remove stale sessions in non-mapping case Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 10/15] api: add mapping support Mira Limbeck
2026-04-30 17:27 ` [PATCH v2 storage 11/15] mapping: iscsi: add discovery-portal config option Mira Limbeck
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
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 [this message]
2026-04-30 17:27 ` [PATCH v2 manager 15/15] api: mapping: add storage mapping path Mira Limbeck

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260430173220.441001-15-m.limbeck@proxmox.com \
    --to=m.limbeck@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal