public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages
@ 2024-11-26 15:23 Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 1/7] plugin: allow volume import of iso, snippets, vztmpl and import Filip Schauer
                   ` (6 more replies)
  0 siblings, 7 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

Add the ability to move a backup, ISO, container template, snippet, or
OVA/OVF between storages and nodes via an API method. Moving a VMA
backup to a Proxmox Backup Server requires the proxmox-vma-to-pbs
package to be installed. Currently only VMA backups can be moved to a
Proxmox Backup Server and moving backups from a Proxmox Backup Server is
currently not supported.

The method can be called from the PVE shell with `pvesm move-volume`:

```
pvesm move-volume <source volume> <target storage> [--target-node <node>] [--delete]
```

For example to move a VMA backup to a Proxmox Backup Server:

```
pvesm move-volume \
    local:backup/vzdump-qemu-100-2024_06_25-13_08_56.vma.zst pbs
```

Or move a container template to another node and delete the source:

```
pvesm move-volume \
    local:vztmpl/devuan-4.0-standard_4.0_amd64.tar.gz local \
    --target-node pvenode2 --delete
```

Or use curl to call the API method:

```
curl https://$APINODE:8006/api2/json/nodes/$SOURCENODE/storage/$SOURCESTORAGE/content/$SOURCEVOLUME \
    --insecure --cookie "$(<cookie)" -H "$(<csrftoken)" -X POST \
    --data-raw "target-storage=$TARGETSTORAGE&target-node=$TARGETNODE"
```

Changes since v4:
* Remove the volume_move subroutine, instead use storage_migrate for
  moving volumes between storages on the same node
* Avoid ssh when moving a volume between storages on the same node
* Add command completion to move-volume parameters
* Make the success message of move-volume less verbose for moves within
  the same node
* utf8 encode/decode backup notes during export/import
* Support the new "import" volume type
* Code cleanup
* Add descriptions to single line commit messages

Changes since v3:
* Split changes into multiple commits
* Remove superfluous parentheses from post-ifs
* Move vma_to_pbs branch from move_volume into its own helper inside
  PBSPlugin
* Use $! instead of $@ to retrieve unlink error in move_volume
* Also support content type 'rootdir'
* Rework permission checks on the move API method
* Fix permissions description on move API method
* Add error for unimplemented content types

Changes since v2:
* Specify permissions for move method
* Add success message to move method
* Limit the move method to non-vdisk volumes
* Check that source and target are not the same in the move method
* Remove the unnecessary movevolume method from pvesm and make the
  move-volume command call the move API method directly
* Fail when trying to move a protected volume with the delete option
  enabled, instead of ignoring the protection
* Change "not yet supported" to "not supported" in messages indicating
  unimplemented features
* Process auxiliary files first when moving a volume locally on a node
* Move a volume instead of copying it when trying to move a volume
  locally on a node with the delete option enabled.
* Use the more general `path` function instead of `filesystem_path` to
  get the path of a volume
* Loosen the required privileges to move an ISO or a container template,
  or when the delete option is not set.
* Move the volume_move sub from PVE::Storage to
  PVE::API2::Storage::Content since it is only used there.
* Explicitly check that storages are path-based in volume_move,
  except when moving a vma to a Proxmox Backup Server

Changes since v1:
* Rename pvesm command to move-volume
* Add a delete option to control whether the source volume should be
  kept
* Move the API method to the POST endpoint of
  /nodes/{node}/storage/{storage}/content/{volume}, replacing the
  experimental copy method that has not been used since its introduction
  in October 2011 883eeea6.
* Implement migrating volumes between nodes

Filip Schauer (7):
  plugin: allow volume import of iso, snippets, vztmpl and import
  api: content: implement moving a volume between storages
  api: content: support moving backups between path based storages
  storage: introduce decompress_archive_into_pipe helper
  support moving VMA backups to PBS
  pvesm: add a move-volume command
  storage migrate: avoid ssh when moving a volume locally

 debian/control                  |   1 +
 src/PVE/API2/Storage/Content.pm | 131 +++++++++++++++++++++++---------
 src/PVE/CLI/pvesm.pm            |   2 +
 src/PVE/Storage.pm              |  80 ++++++++++++-------
 src/PVE/Storage/PBSPlugin.pm    |  65 ++++++++++++++++
 src/PVE/Storage/Plugin.pm       | 112 ++++++++++++++++++++-------
 6 files changed, 299 insertions(+), 92 deletions(-)

-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 1/7] plugin: allow volume import of iso, snippets, vztmpl and import
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 2/7] api: content: implement moving a volume between storages Filip Schauer
                   ` (5 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

Extend volume import functionality to support 'iso', 'snippets',
'vztmpl', and 'import' types, in addition to the existing support for
'images' and 'rootdir'. This is a prerequisite for the ability to move
ISOs, snippets and container templates between nodes.

Existing behavior for importing VM disks and container volumes remains
unchanged.

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 src/PVE/Storage/Plugin.pm | 72 ++++++++++++++++++++++++++-------------
 1 file changed, 48 insertions(+), 24 deletions(-)

diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
index 0b0b5a8..c010d9c 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -1684,6 +1684,8 @@ sub volume_export_formats {
 	    or return;
 	my ($size, $format) = file_size_info($file);
 
+	return ('raw+size') if !defined($format);
+
 	if ($with_snapshots) {
 	    return ($format.'+size') if ($format eq 'qcow2' || $format eq 'vmdk');
 	    return ();
@@ -1712,14 +1714,18 @@ sub volume_import {
 
     # XXX: Should we bother with conversion routines at this level? This won't
     # happen without manual CLI usage, so for now we just error out...
-    die "cannot import format $format into a file of format $file_format\n"
-	if $data_format ne $file_format && !($data_format eq 'tar' && $file_format eq 'subvol');
+    if (($vtype eq 'images' || $vtype eq 'rootdir') && $data_format ne $file_format &&
+	!($data_format eq 'tar' && $file_format eq 'subvol')
+    ) {
+	die "cannot import format $format into a file of format $file_format\n";
+    }
 
     # Check for an existing file first since interrupting alloc_image doesn't
     # free it.
     my $file = $class->path($scfg, $volname, $storeid);
     if (-e $file) {
-	die "file '$file' already exists\n" if !$allow_rename;
+	die "file '$file' already exists\n"
+	    if !$allow_rename || ($vtype ne 'images' && $vtype ne 'rootdir');
 	warn "file '$file' already exists - importing with a different name\n";
 	$name = undef;
     }
@@ -1727,29 +1733,44 @@ sub volume_import {
     my ($size) = read_common_header($fh);
     $size = int($size/1024);
 
-    eval {
-	my $allocname = $class->alloc_image($storeid, $scfg, $vmid, $file_format, $name, $size);
-	my $oldname = $volname;
-	$volname = $allocname;
-	if (defined($name) && $allocname ne $oldname) {
-	    die "internal error: unexpected allocated name: '$allocname' != '$oldname'\n";
+    if ($vtype eq 'images' || $vtype eq 'rootdir') {
+	eval {
+	    my $allocname = $class->alloc_image($storeid, $scfg, $vmid, $file_format, $name, $size);
+	    my $oldname = $volname;
+	    $volname = $allocname;
+	    if (defined($name) && $allocname ne $oldname) {
+		die "internal error: unexpected allocated name: '$allocname' != '$oldname'\n";
+	    }
+	    my $file = $class->path($scfg, $volname, $storeid)
+		or die "internal error: failed to get path to newly allocated volume $volname\n";
+	    if ($data_format eq 'raw' || $data_format eq 'qcow2' || $data_format eq 'vmdk') {
+		run_command(['dd', "of=$file", 'conv=sparse', 'bs=64k'],
+		    input => '<&'.fileno($fh));
+	    } elsif ($data_format eq 'tar') {
+		run_command(['tar', @COMMON_TAR_FLAGS, '-C', $file, '-xf', '-'],
+		    input => '<&'.fileno($fh));
+	    } else {
+		die "volume import format '$format' not available for $class";
+	    }
+	};
+	if (my $err = $@) {
+	    eval { $class->free_image($storeid, $scfg, $volname, 0, $file_format) };
+	    warn $@ if $@;
+	    die $err;
 	}
-	my $file = $class->path($scfg, $volname, $storeid)
-	    or die "internal error: failed to get path to newly allocated volume $volname\n";
-	if ($data_format eq 'raw' || $data_format eq 'qcow2' || $data_format eq 'vmdk') {
-	    run_command(['dd', "of=$file", 'conv=sparse', 'bs=64k'],
-	                input => '<&'.fileno($fh));
-	} elsif ($data_format eq 'tar') {
-	    run_command(['tar', @COMMON_TAR_FLAGS, '-C', $file, '-xf', '-'],
-	                input => '<&'.fileno($fh));
-	} else {
-	    die "volume import format '$format' not available for $class";
+    } elsif (grep { $vtype eq $_ } qw(import iso snippets vztmpl)) {
+	eval {
+	    run_command(['dd', "of=$file", 'bs=64k'], input => '<&'.fileno($fh));
+	};
+	if (my $err = $@) {
+	    if (-e $file) {
+		eval { unlink($file) };
+		warn $@ if $@;
+	    }
+	    die $err;
 	}
-    };
-    if (my $err = $@) {
-	eval { $class->free_image($storeid, $scfg, $volname, 0, $file_format) };
-	warn $@ if $@;
-	die $err;
+    } else {
+	die "importing volume of type '$vtype' not implemented\n";
     }
 
     return "$storeid:$volname";
@@ -1759,6 +1780,9 @@ sub volume_import_formats {
     my ($class, $scfg, $storeid, $volname, $snapshot, $base_snapshot, $with_snapshots) = @_;
     if ($scfg->{path} && !defined($base_snapshot)) {
 	my $format = ($class->parse_volname($volname))[6];
+
+	return ('raw+size') if !defined($format);
+
 	if ($with_snapshots) {
 	    return ($format.'+size') if ($format eq 'qcow2' || $format eq 'vmdk');
 	    return ();
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 2/7] api: content: implement moving a volume between storages
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 1/7] plugin: allow volume import of iso, snippets, vztmpl and import Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 3/7] api: content: support moving backups between path based storages Filip Schauer
                   ` (4 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

Add the ability to move an iso, snippet or vztmpl between storages and
nodes.

Use either curl to call the API method:

```
curl https://$APINODE:8006/api2/json/nodes/$SOURCENODE/storage/$SOURCESTORAGE/content/$SOURCEVOLUME \
    --insecure --cookie "$(<cookie)" -H "$(<csrftoken)" -X POST \
    --data-raw "target-storage=$TARGETSTORAGE&target-node=$TARGETNODE"
```

Or use pvesh:

```
pvesh create /nodes/$SOURCENODE/storage/$SOURCESTORAGE/content/$SOURCEVOLUME \
    --target-storage $TARGETSTORAGE --target-node $TARGETNODE
```

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 src/PVE/API2/Storage/Content.pm | 111 +++++++++++++++++++++-----------
 1 file changed, 75 insertions(+), 36 deletions(-)

diff --git a/src/PVE/API2/Storage/Content.pm b/src/PVE/API2/Storage/Content.pm
index fe0ad4a..ac451dc 100644
--- a/src/PVE/API2/Storage/Content.pm
+++ b/src/PVE/API2/Storage/Content.pm
@@ -2,7 +2,10 @@ package PVE::API2::Storage::Content;
 
 use strict;
 use warnings;
-use Data::Dumper;
+
+use Errno qw(ENOENT);
+use File::Basename;
+use File::Copy qw(copy move);
 
 use PVE::SafeSyslog;
 use PVE::Cluster;
@@ -484,29 +487,47 @@ __PACKAGE__->register_method ({
     }});
 
 __PACKAGE__->register_method ({
-    name => 'copy',
+    name => 'move',
     path => '{volume}',
     method => 'POST',
-    description => "Copy a volume. This is experimental code - do not use.",
+    description => "Move a volume.",
+    permissions => {
+	description => "If the --delete option is used, the 'Datastore.Allocate' privilege is " .
+	    "required on the source storage. " .
+	    "Without --delete, 'Datastore.AllocateSpace' is required on the target storage. " .
+	    "When moving a backup, 'VM.Backup' is required on the VM or container.",
+	user => 'all',
+    },
     protected => 1,
     proxyto => 'node',
     parameters => {
-    	additionalProperties => 0,
+	additionalProperties => 0,
 	properties => {
 	    node => get_standard_option('pve-node'),
-	    storage => get_standard_option('pve-storage-id', { optional => 1}),
+	    storage => get_standard_option('pve-storage-id', {
+		optional => 1,
+		completion => \&PVE::Storage::complete_storage_enabled,
+	    }),
 	    volume => {
 		description => "Source volume identifier",
 		type => 'string',
+		completion => \&PVE::Storage::complete_volume,
 	    },
-	    target => {
-		description => "Target volume identifier",
-		type => 'string',
-	    },
-	    target_node => get_standard_option('pve-node',  {
+	    'target-storage' => get_standard_option('pve-storage-id', {
+		description => "Target storage",
+		completion => \&PVE::Storage::complete_storage_enabled,
+	    }),
+	    'target-node' => get_standard_option('pve-node',  {
 		description => "Target node. Default is local node.",
 		optional => 1,
 	    }),
+	    delete => {
+		type => 'boolean',
+		description => "Delete the original volume after a successful copy. " .
+		    "By default the original is kept.",
+		optional => 1,
+		default => 0,
+	    },
 	},
     },
     returns => {
@@ -515,43 +536,61 @@ __PACKAGE__->register_method ({
     code => sub {
 	my ($param) = @_;
 
-	my $rpcenv = PVE::RPCEnvironment::get();
-
-	my $user = $rpcenv->get_user();
-
-	my $target_node = $param->{target_node} || PVE::INotify::nodename();
-	# pvesh examples
-	# cd /nodes/localhost/storage/local/content
-	# pve:/> create local:103/vm-103-disk-1.raw -target local:103/vm-103-disk-2.raw
-	# pve:/> create 103/vm-103-disk-1.raw -target 103/vm-103-disk-3.raw
+	my $src_volid = $real_volume_id->($param->{storage}, $param->{volume});
+	my $dst_storeid = $param->{'target-storage'};
+	my ($src_storeid, $volname) = PVE::Storage::parse_volume_id($src_volid);
+	my $src_node = PVE::INotify::nodename();
+	my $dst_node = $param->{'target-node'} || $src_node;
+	my $delete = $param->{delete};
 
-	my $src_volid = &$real_volume_id($param->{storage}, $param->{volume});
-	my $dst_volid = &$real_volume_id($param->{storage}, $param->{target});
-
-	print "DEBUG: COPY $src_volid TO $dst_volid\n";
+	die "source and target cannot be the same\n"
+	    if $src_node eq $dst_node && $src_storeid eq $dst_storeid;
 
 	my $cfg = PVE::Storage::config();
 
-	# do all parameter checks first
+	my ($vtype) = PVE::Storage::parse_volname($cfg, $src_volid);
+	die "use pct move-volume or qm disk move" if $vtype eq 'images' || $vtype eq 'rootdir';
+	die "moving volume of type '$vtype' not implemented\n"
+	    if (!grep { $vtype eq $_ } qw(import iso snippets vztmpl));
 
-	# then do all short running task (to raise errors before we go to background)
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $user = $rpcenv->get_user();
 
-	# then start the worker task
-	my $worker = sub  {
-	    my $upid = shift;
+	PVE::Storage::check_volume_access($rpcenv, $user, $cfg, undef, $src_volid);
 
-	    print "DEBUG: starting worker $upid\n";
+	if ($delete) {
+	    $rpcenv->check($user, "/storage/$src_storeid", ["Datastore.Allocate"]);
+	} else {
+	    $rpcenv->check($user, "/storage/$dst_storeid", ["Datastore.AllocateSpace"]);
+	}
 
-	    my ($target_sid, $target_volname) = PVE::Storage::parse_volume_id($dst_volid);
-	    #my $target_ip = PVE::Cluster::remote_node_ip($target_node);
+	my $worker = sub {
+	    PVE::Storage::storage_check_enabled($cfg, $dst_storeid, $dst_node);
+	    my $sshinfo;
+
+	    if ($src_node eq $dst_node) {
+		$sshinfo = {
+		    ip => "localhost",
+		    name => $dst_node,
+		};
+	    } else {
+		$sshinfo = PVE::SSHInfo::get_ssh_info($dst_node);
+	    }
 
-	    # you need to get this working (fails currently, because storage_migrate() uses
-	    # ssh to connect to local host (which is not needed
-	    my $sshinfo = PVE::SSHInfo::get_ssh_info($target_node);
-	    PVE::Storage::storage_migrate($cfg, $src_volid, $sshinfo, $target_sid, {'target_volname' => $target_volname});
+	    my $opts = { 'target_volname' => $volname };
+	    PVE::Storage::storage_migrate($cfg, $src_volid, $sshinfo, $dst_storeid, $opts);
 
-	    print "DEBUG: end worker $upid\n";
+	    if ($delete) {
+		my $src_path = PVE::Storage::abs_filesystem_path($cfg, $src_volid);
+		PVE::Storage::archive_remove($src_path, 1);
+	    }
 
+	    if ($src_node eq $dst_node) {
+		print "Moved volume '$src_volid' to '$dst_storeid'\n";
+	    } else {
+		print "Moved volume '$src_volid' on node '$src_node'"
+		    ." to '$dst_storeid' on node '$dst_node'\n";
+	    }
 	};
 
 	return $rpcenv->fork_worker('imgcopy', undef, $user, $worker);
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 3/7] api: content: support moving backups between path based storages
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 1/7] plugin: allow volume import of iso, snippets, vztmpl and import Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 2/7] api: content: implement moving a volume between storages Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 4/7] storage: introduce decompress_archive_into_pipe helper Filip Schauer
                   ` (3 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

This commit adds the "backup+size" export format. When this format is
used, the data stream starts with metadata of the backup (protected flag
& notes) followed by the contents of the backup archive.

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 src/PVE/API2/Storage/Content.pm | 15 ++++++++++--
 src/PVE/Storage.pm              | 10 +++++++-
 src/PVE/Storage/Plugin.pm       | 42 +++++++++++++++++++++++++++++----
 3 files changed, 60 insertions(+), 7 deletions(-)

diff --git a/src/PVE/API2/Storage/Content.pm b/src/PVE/API2/Storage/Content.pm
index ac451dc..9ee3c51 100644
--- a/src/PVE/API2/Storage/Content.pm
+++ b/src/PVE/API2/Storage/Content.pm
@@ -548,10 +548,10 @@ __PACKAGE__->register_method ({
 
 	my $cfg = PVE::Storage::config();
 
-	my ($vtype) = PVE::Storage::parse_volname($cfg, $src_volid);
+	my ($vtype, undef, $ownervm) = PVE::Storage::parse_volname($cfg, $src_volid);
 	die "use pct move-volume or qm disk move" if $vtype eq 'images' || $vtype eq 'rootdir';
 	die "moving volume of type '$vtype' not implemented\n"
-	    if (!grep { $vtype eq $_ } qw(import iso snippets vztmpl));
+	    if (!grep { $vtype eq $_ } qw(backup import iso snippets vztmpl));
 
 	my $rpcenv = PVE::RPCEnvironment::get();
 	my $user = $rpcenv->get_user();
@@ -560,10 +560,21 @@ __PACKAGE__->register_method ({
 
 	if ($delete) {
 	    $rpcenv->check($user, "/storage/$src_storeid", ["Datastore.Allocate"]);
+
+	    if ($vtype eq 'backup') {
+		my $src_cfg = PVE::Storage::storage_config($cfg, $src_storeid);
+		my $src_plugin = PVE::Storage::Plugin->lookup($src_cfg->{type});
+		my $protected = $src_plugin->get_volume_attribute($src_cfg, $src_storeid, $volname, 'protected');
+		die "cannot delete protected backup\n" if $protected;
+	    }
 	} else {
 	    $rpcenv->check($user, "/storage/$dst_storeid", ["Datastore.AllocateSpace"]);
 	}
 
+	if ($vtype eq 'backup' && $ownervm) {
+	    $rpcenv->check($user, "/vms/$ownervm", ['VM.Backup']);
+	}
+
 	my $worker = sub {
 	    PVE::Storage::storage_check_enabled($cfg, $dst_storeid, $dst_node);
 	    my $sshinfo;
diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index 230c57b..ae190cd 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -48,7 +48,15 @@ use constant APIVER => 10;
 # see https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html
 use constant APIAGE => 1;
 
-our $KNOWN_EXPORT_FORMATS = ['raw+size', 'tar+size', 'qcow2+size', 'vmdk+size', 'zfs', 'btrfs'];
+our $KNOWN_EXPORT_FORMATS = [
+    'raw+size',
+    'tar+size',
+    'qcow2+size',
+    'vmdk+size',
+    'zfs',
+    'btrfs',
+    'backup+size'
+];
 
 # load standard plugins
 PVE::Storage::DirPlugin->register();
diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
index c010d9c..4c3c3ac 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -1624,6 +1624,8 @@ sub prune_backups {
 #     unprivileged container. In other words, this is from the root user
 #     namespace's point of view with no uid-mapping in effect.
 #     As produced via `tar -C vm-100-disk-1.subvol -cpf TheOutputFile.dat .`
+#   backup+size: (backups only)
+#     A raw binary data stream prefixed with a protection flag and notes.
 
 # Plugins may reuse these helpers. Changes to the header format should be
 # reflected by changes to the function prototypes.
@@ -1670,7 +1672,18 @@ sub volume_export {
 	    die $err_msg if $file_format ne 'subvol';
 	    write_common_header($fh, $size);
 	    run_command(['tar', @COMMON_TAR_FLAGS, '-cf', '-', '-C', $file, '.'],
-	                output => '>&'.fileno($fh));
+			output => '>&'.fileno($fh));
+	    return;
+	} elsif ($format eq 'backup+size') {
+	    write_common_header($fh, $size);
+	    my $protected = $class->get_volume_attribute(
+		$scfg, $storeid, $volname, 'protected') ? 1 : 0;
+	    my $notes = Encode::encode(
+		'utf8', $class->get_volume_attribute($scfg, $storeid, $volname, 'notes')) // "";
+	    syswrite($fh, pack("C", $protected), 1);
+	    syswrite($fh, pack("Q<", length($notes)), 8);
+	    syswrite($fh, $notes, length($notes));
+	    run_command(['dd', "if=$file", "bs=4k", "status=progress"], output => '>&'.fileno($fh));
 	    return;
 	}
     }
@@ -1684,6 +1697,9 @@ sub volume_export_formats {
 	    or return;
 	my ($size, $format) = file_size_info($file);
 
+	my ($vtype) = $class->parse_volname($volname);
+
+	return ('backup+size') if $vtype eq 'backup';
 	return ('raw+size') if !defined($format);
 
 	if ($with_snapshots) {
@@ -1701,7 +1717,7 @@ sub volume_import {
     my ($class, $scfg, $storeid, $fh, $volname, $format, $snapshot, $base_snapshot, $with_snapshots, $allow_rename) = @_;
 
     die "volume import format '$format' not available for $class\n"
-	if $format !~ /^(raw|tar|qcow2|vmdk)\+size$/;
+	if $format !~ /^(raw|tar|qcow2|vmdk|backup)\+size$/;
     my $data_format = $1;
 
     die "format $format cannot be imported without snapshots\n"
@@ -1758,7 +1774,19 @@ sub volume_import {
 	    warn $@ if $@;
 	    die $err;
 	}
-    } elsif (grep { $vtype eq $_ } qw(import iso snippets vztmpl)) {
+    } elsif (grep { $vtype eq $_ } qw(import iso snippets vztmpl backup)) {
+	my $protected;
+	my $notes;
+
+	if ($vtype eq 'backup') {
+	    sysread($fh, $protected, 1);
+	    $protected = unpack('C', $protected);
+	    sysread($fh, my $notes_len, 8);
+	    $notes_len = unpack('Q<', $notes_len);
+	    sysread($fh, $notes, $notes_len);
+	    $notes = Encode::decode('utf8', $notes) // "";
+	}
+
 	eval {
 	    run_command(['dd', "of=$file", 'bs=64k'], input => '<&'.fileno($fh));
 	};
@@ -1769,6 +1797,11 @@ sub volume_import {
 	    }
 	    die $err;
 	}
+
+	if ($vtype eq 'backup') {
+	    $class->update_volume_attribute($scfg, $storeid, $volname, 'protected', $protected);
+	    $class->update_volume_attribute($scfg, $storeid, $volname, 'notes', $notes);
+	}
     } else {
 	die "importing volume of type '$vtype' not implemented\n";
     }
@@ -1779,8 +1812,9 @@ sub volume_import {
 sub volume_import_formats {
     my ($class, $scfg, $storeid, $volname, $snapshot, $base_snapshot, $with_snapshots) = @_;
     if ($scfg->{path} && !defined($base_snapshot)) {
-	my $format = ($class->parse_volname($volname))[6];
+	my ($vtype, $format) = ($class->parse_volname($volname))[0, 6];
 
+	return ('backup+size') if $vtype eq 'backup';
 	return ('raw+size') if !defined($format);
 
 	if ($with_snapshots) {
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 4/7] storage: introduce decompress_archive_into_pipe helper
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
                   ` (2 preceding siblings ...)
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 3/7] api: content: support moving backups between path based storages Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 5/7] support moving VMA backups to PBS Filip Schauer
                   ` (2 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

Extract the file decompression code into its own reusable subroutine.

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 src/PVE/Storage.pm | 64 +++++++++++++++++++++++++++++-----------------
 1 file changed, 40 insertions(+), 24 deletions(-)

diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index ae190cd..5d25a2a 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -1707,6 +1707,45 @@ sub extract_vzdump_config_tar {
     return wantarray ? ($raw, $file) : $raw;
 }
 
+sub decompress_archive_into_pipe {
+    my ($archive, $cmd, $outfunc) = @_;
+
+    my $info = archive_info($archive);
+    die "archive is not compressed\n" if !$info->{compression};
+    my $decompressor = $info->{decompressor};
+    my $full_cmd = [ [@$decompressor, $archive], $cmd ];
+
+    # lzop/zcat exits with 1 when the pipe is closed early,
+    # detect this and ignore the exit code later
+    my $broken_pipe;
+    my $errstring;
+    my $err = sub {
+	my $output = shift;
+	if (
+	    $output =~ m/lzop: Broken pipe: <stdout>/
+	    || $output =~ m/gzip: stdout: Broken pipe/
+	    || $output =~ m/zstd: error 70 : Write error.*Broken pipe/
+	) {
+	    $broken_pipe = 1;
+	} elsif (!defined ($errstring) && $output !~ m/^\s*$/) {
+	    $errstring = "failed to decompress archive: $output\n";
+	}
+    };
+
+    my $rc = eval { run_command($full_cmd, outfunc => $outfunc, errfunc => $err, noerr => 1) };
+    my $rerr = $@;
+
+    $broken_pipe ||= $rc == 141; # broken pipe from cmd POV
+
+    if (!$errstring && !$broken_pipe && $rc != 0) {
+	die "$rerr\n" if $rerr;
+	die "archive decompression failed with exit code $rc\n";
+    }
+    die "$errstring\n" if $errstring;
+
+    return;
+}
+
 sub extract_vzdump_config_vma {
     my ($archive, $comp) = @_;
 
@@ -1718,30 +1757,7 @@ sub extract_vzdump_config_vma {
     my $decompressor = $info->{decompressor};
 
     if ($comp) {
-	my $cmd = [ [@$decompressor, $archive], ["vma", "config", "-"] ];
-
-	# lzop/zcat exits with 1 when the pipe is closed early by vma, detect this and ignore the exit code later
-	my $broken_pipe;
-	my $errstring;
-	my $err = sub {
-	    my $output = shift;
-	    if ($output =~ m/lzop: Broken pipe: <stdout>/ || $output =~ m/gzip: stdout: Broken pipe/ || $output =~ m/zstd: error 70 : Write error.*Broken pipe/) {
-		$broken_pipe = 1;
-	    } elsif (!defined ($errstring) && $output !~ m/^\s*$/) {
-		$errstring = "Failed to extract config from VMA archive: $output\n";
-	    }
-	};
-
-	my $rc = eval { run_command($cmd, outfunc => $out, errfunc => $err, noerr => 1) };
-	my $rerr = $@;
-
-	$broken_pipe ||= $rc == 141; # broken pipe from vma POV
-
-	if (!$errstring && !$broken_pipe && $rc != 0) {
-	    die "$rerr\n" if $rerr;
-	    die "config extraction failed with exit code $rc\n";
-	}
-	die "$errstring\n" if $errstring;
+	decompress_archive_into_pipe($archive, ["vma", "config", "-"], $out);
     } else {
 	run_command(["vma", "config", $archive], outfunc => $out);
     }
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 5/7] support moving VMA backups to PBS
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
                   ` (3 preceding siblings ...)
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 4/7] storage: introduce decompress_archive_into_pipe helper Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 6/7] pvesm: add a move-volume command Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 7/7] storage migrate: avoid ssh when moving a volume locally Filip Schauer
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

Extend the move API to support moving VMA backups to a Proxmox Backup
Server.

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 debian/control                  |  1 +
 src/PVE/API2/Storage/Content.pm | 53 +++++++++++++++------------
 src/PVE/Storage/PBSPlugin.pm    | 65 +++++++++++++++++++++++++++++++++
 3 files changed, 96 insertions(+), 23 deletions(-)

diff --git a/debian/control b/debian/control
index e04fad0..fa50271 100644
--- a/debian/control
+++ b/debian/control
@@ -46,6 +46,7 @@ Depends: bzip2,
          nfs-common,
          proxmox-backup-client (>= 2.1.10~),
          proxmox-backup-file-restore,
+         proxmox-vma-to-pbs (>= 0.0.2),
          pve-cluster (>= 5.0-32),
          smartmontools,
          smbclient,
diff --git a/src/PVE/API2/Storage/Content.pm b/src/PVE/API2/Storage/Content.pm
index 9ee3c51..c0e2a4e 100644
--- a/src/PVE/API2/Storage/Content.pm
+++ b/src/PVE/API2/Storage/Content.pm
@@ -10,6 +10,7 @@ use File::Copy qw(copy move);
 use PVE::SafeSyslog;
 use PVE::Cluster;
 use PVE::Storage;
+use PVE::Storage::PBSPlugin;
 use PVE::INotify;
 use PVE::Exception qw(raise_param_exc);
 use PVE::RPCEnvironment;
@@ -557,12 +558,12 @@ __PACKAGE__->register_method ({
 	my $user = $rpcenv->get_user();
 
 	PVE::Storage::check_volume_access($rpcenv, $user, $cfg, undef, $src_volid);
+	my $src_cfg = PVE::Storage::storage_config($cfg, $src_storeid);
 
 	if ($delete) {
 	    $rpcenv->check($user, "/storage/$src_storeid", ["Datastore.Allocate"]);
 
 	    if ($vtype eq 'backup') {
-		my $src_cfg = PVE::Storage::storage_config($cfg, $src_storeid);
 		my $src_plugin = PVE::Storage::Plugin->lookup($src_cfg->{type});
 		my $protected = $src_plugin->get_volume_attribute($src_cfg, $src_storeid, $volname, 'protected');
 		die "cannot delete protected backup\n" if $protected;
@@ -577,30 +578,36 @@ __PACKAGE__->register_method ({
 
 	my $worker = sub {
 	    PVE::Storage::storage_check_enabled($cfg, $dst_storeid, $dst_node);
-	    my $sshinfo;
 
-	    if ($src_node eq $dst_node) {
-		$sshinfo = {
-		    ip => "localhost",
-		    name => $dst_node,
-		};
+	    my $dst_cfg = PVE::Storage::storage_config($cfg, $dst_storeid);
+	    if ($vtype eq 'backup' && $dst_cfg->{type} eq 'pbs') {
+		PVE::Storage::PBSPlugin::vma_to_pbs($src_cfg, $src_volid, $dst_cfg, $dst_storeid);
 	    } else {
-		$sshinfo = PVE::SSHInfo::get_ssh_info($dst_node);
-	    }
-
-	    my $opts = { 'target_volname' => $volname };
-	    PVE::Storage::storage_migrate($cfg, $src_volid, $sshinfo, $dst_storeid, $opts);
-
-	    if ($delete) {
-		my $src_path = PVE::Storage::abs_filesystem_path($cfg, $src_volid);
-		PVE::Storage::archive_remove($src_path, 1);
-	    }
-
-	    if ($src_node eq $dst_node) {
-		print "Moved volume '$src_volid' to '$dst_storeid'\n";
-	    } else {
-		print "Moved volume '$src_volid' on node '$src_node'"
-		    ." to '$dst_storeid' on node '$dst_node'\n";
+		my $sshinfo;
+
+		if ($src_node eq $dst_node) {
+		    $sshinfo = {
+			ip => "localhost",
+			name => $dst_node,
+		    };
+		} else {
+		    $sshinfo = PVE::SSHInfo::get_ssh_info($dst_node);
+		}
+
+		my $opts = { 'target_volname' => $volname };
+		PVE::Storage::storage_migrate($cfg, $src_volid, $sshinfo, $dst_storeid, $opts);
+
+		if ($delete) {
+		    my $src_path = PVE::Storage::abs_filesystem_path($cfg, $src_volid);
+		    PVE::Storage::archive_remove($src_path, 1);
+		}
+
+		if ($src_node eq $dst_node) {
+		    print "Moved volume '$src_volid' to '$dst_storeid'\n";
+		} else {
+		    print "Moved volume '$src_volid' on node '$src_node'"
+			." to '$dst_storeid' on node '$dst_node'\n";
+		}
 	    }
 	};
 
diff --git a/src/PVE/Storage/PBSPlugin.pm b/src/PVE/Storage/PBSPlugin.pm
index 0808bcc..4f8a05d 100644
--- a/src/PVE/Storage/PBSPlugin.pm
+++ b/src/PVE/Storage/PBSPlugin.pm
@@ -6,6 +6,7 @@ use strict;
 use warnings;
 
 use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC);
+use File::Basename;
 use IO::File;
 use JSON;
 use MIME::Base64 qw(decode_base64);
@@ -971,4 +972,68 @@ sub volume_has_feature {
     return undef;
 }
 
+sub vma_to_pbs {
+    my ($source_scfg, $source_volid, $target_scfg, $target_storeid) = @_;
+
+    my $source_plugin = PVE::Storage::Plugin->lookup($source_scfg->{type});
+    my $target_plugin = PVE::Storage::Plugin->lookup($target_scfg->{type});
+    my ($source_storeid, $source_volname) = PVE::Storage::parse_volume_id($source_volid, 0);
+    my $source_path = $source_plugin->path($source_scfg, $source_volname, $source_storeid);
+    my $info = PVE::Storage::archive_info($source_path);
+    die "moving non-VMA backups to a Proxmox Backup Server is not supported\n"
+	if $info->{format} ne 'vma';
+
+    my $repo = PVE::PBSClient::get_repository($target_scfg);
+    my $vmid = ($source_plugin->parse_volname($source_volname))[2];
+    my $fingerprint = $target_scfg->{fingerprint};
+    my $password = PVE::Storage::PBSPlugin::pbs_password_file_name($target_scfg, $target_storeid);
+    my $namespace = $target_scfg->{namespace};
+    my $keyfile = PVE::Storage::PBSPlugin::pbs_encryption_key_file_name(
+	$target_scfg, $target_storeid);
+    my $master_keyfile = PVE::Storage::PBSPlugin::pbs_master_pubkey_file_name(
+	$target_scfg, $target_storeid);
+
+    my $comp = $info->{compression};
+    my $backup_time = $info->{ctime};
+    my $source_dirname = dirname($source_path);
+    my $log_file_path = "$source_dirname/$info->{logfilename}";
+    my $notes_file_path = "$source_dirname/$info->{notesfilename}";
+
+    my $vma_to_pbs_cmd = [
+	"vma-to-pbs",
+	"--repository", $repo,
+	"--vmid", $vmid,
+	"--fingerprint", $fingerprint,
+	"--password-file", $password,
+	"--backup-time", $backup_time,
+	"--compress",
+    ];
+
+    push @$vma_to_pbs_cmd, "--ns", $namespace if $namespace;
+    push @$vma_to_pbs_cmd, "--log-file", $log_file_path if -e $log_file_path;
+    push @$vma_to_pbs_cmd, "--notes-file", $notes_file_path if -e $notes_file_path;
+    push @$vma_to_pbs_cmd, "--encrypt", "--keyfile", $keyfile if -e $keyfile;
+    push @$vma_to_pbs_cmd, "--master-keyfile", $master_keyfile if -e $master_keyfile;
+
+    if ($comp) {
+	PVE::Storage::decompress_archive_into_pipe($source_path, $vma_to_pbs_cmd);
+    } else {
+	push @$vma_to_pbs_cmd, $source_path;
+	run_command($vma_to_pbs_cmd);
+    }
+
+    my $protected = $source_plugin->get_volume_attribute(
+	$source_scfg, $source_storeid, $source_volname, 'protected');
+
+    if ($protected) {
+	my $target_volid = PVE::Storage::PBSPlugin::print_volid(
+	    $target_storeid, 'vm', $vmid, $backup_time);
+	my (undef, $target_volname) = PVE::Storage::parse_volume_id($target_volid, 0);
+	$target_plugin->update_volume_attribute(
+	    $target_scfg, $target_storeid, $target_volname, 'protected', 1);
+    }
+
+    return;
+}
+
 1;
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 6/7] pvesm: add a move-volume command
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
                   ` (4 preceding siblings ...)
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 5/7] support moving VMA backups to PBS Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 7/7] storage migrate: avoid ssh when moving a volume locally Filip Schauer
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

The method can be called from the PVE shell with `pvesm move-volume`:

```
pvesm move-volume <source volume> <target storage> [--target-node <node>] [--delete]
```

For example to move a VMA backup to a Proxmox Backup Server:

```
pvesm move-volume \
    local:backup/vzdump-qemu-100-2024_06_25-13_08_56.vma.zst pbs
```

Or move a container template to another node and delete the source:

```
pvesm move-volume \
    local:vztmpl/devuan-4.0-standard_4.0_amd64.tar.gz local \
    --target-node pvenode2 --delete
```

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 src/PVE/CLI/pvesm.pm | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/PVE/CLI/pvesm.pm b/src/PVE/CLI/pvesm.pm
index 9b9676b..8031fc1 100755
--- a/src/PVE/CLI/pvesm.pm
+++ b/src/PVE/CLI/pvesm.pm
@@ -690,6 +690,8 @@ our $cmddef = {
 	print "APIVER $res->{apiver}\n";
 	print "APIAGE $res->{apiage}\n";
     }],
+    'move-volume' => [ "PVE::API2::Storage::Content", 'move', ['volume', 'target-storage'],
+		{ node => $nodename } ],
     'prune-backups' => [ __PACKAGE__, 'prunebackups', ['storage'], { node => $nodename }, sub {
 	my $res = shift;
 
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [pve-devel] [PATCH storage v5 7/7] storage migrate: avoid ssh when moving a volume locally
  2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
                   ` (5 preceding siblings ...)
  2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 6/7] pvesm: add a move-volume command Filip Schauer
@ 2024-11-26 15:23 ` Filip Schauer
  6 siblings, 0 replies; 8+ messages in thread
From: Filip Schauer @ 2024-11-26 15:23 UTC (permalink / raw)
  To: pve-devel

Avoid the overhead of SSH when moving a volume between storages on the
same node.

Signed-off-by: Filip Schauer <f.schauer@proxmox.com>
---
 src/PVE/Storage.pm | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index 5d25a2a..c1f383a 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -823,8 +823,6 @@ sub storage_migrate {
 
     my $target_volid = "${target_storeid}:${target_volname}";
 
-    my $target_ip = $target_sshinfo->{ip};
-
     my $ssh = PVE::SSHInfo::ssh_info_to_command($target_sshinfo);
     my $ssh_base = PVE::SSHInfo::ssh_info_to_command_base($target_sshinfo);
     local $ENV{RSYNC_RSH} = PVE::Tools::cmd2string($ssh_base);
@@ -844,7 +842,9 @@ sub storage_migrate {
 	$import_fn = "tcp://$net";
     }
 
-    my $recv = [ @$ssh, '--', $volume_import_prepare->($target_volid, $format, $import_fn, $opts)->@* ];
+    my $recv = [];
+    push @$recv, (@$ssh, '--') if $target_sshinfo->{ip} ne "localhost";
+    push @$recv, ($volume_import_prepare->($target_volid, $format, $import_fn, $opts)->@*);
 
     my $new_volid;
     my $pattern = volume_imported_message(undef, 1);
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 8+ messages in thread

end of thread, other threads:[~2024-11-26 15:24 UTC | newest]

Thread overview: 8+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2024-11-26 15:23 [pve-devel] [PATCH storage v5 0/7] support moving volumes between storages Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 1/7] plugin: allow volume import of iso, snippets, vztmpl and import Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 2/7] api: content: implement moving a volume between storages Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 3/7] api: content: support moving backups between path based storages Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 4/7] storage: introduce decompress_archive_into_pipe helper Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 5/7] support moving VMA backups to PBS Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 6/7] pvesm: add a move-volume command Filip Schauer
2024-11-26 15:23 ` [pve-devel] [PATCH storage v5 7/7] storage migrate: avoid ssh when moving a volume locally Filip Schauer

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