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