[pve-devel] [PATCH qemu-server v4 1/2] Move importdisk from qm to API

Fabian Grünbichler f.gruenbichler at proxmox.com
Wed Oct 28 14:01:59 CET 2020


needs a rebase ;)

On September 15, 2020 1:33 pm, Dominic Jäger wrote:
> Required to create a GUI for importdisk.
> 
> Add parameters that enable directly attaching the disk to a bus/device with all
> known disk options. This avoids intermediate steps as unused disk.
> 
> We allow different places as source
> * Regular VM images on PVE storages (Normal users + root)
> * Other disk images on PVE storages (Normal users + root) (they have already
>     been displayed in the FileSelector before)
> * Any path (root only)
> 
> Using any path for normal users would be security risk. But if you have to
> move a disk image to a PVE storage you only are not too many steps
> * rename image according to PVE schema
> * qm rescan
> * double click in GUI to attach
> away from making the whole importdisk obsolete.
> 
> Enabling arbitrary paths for root additionally makes it unnecessary to move the
> disk image or create an appropriate storage. That means no knowledge about PVE
> storage content naming schemes ("why do I have to move it into a images/<vmid>
> subfolder of a directory based storage?") is required. Importing could then be
> comprised of only two steps:
> 1. mount external drive (hopefully most PVE admins can figure this out)
> 2. Click through GUI window and insert /mount/externalDrive/exportedFromEsxi.vmdk
> 
> Uploading disk images to avoid the PVE storage naming knowledge can still be
> added in the future. However, such a function might not be ideal for big images
> because
> * Upload via browser might fail easily?
> * Potentially copying huge images from server to local to server?
> 
> So having the absolute path as an option between renaming everything manually
> and just uploading it in GUI without CLI knowledge looks like a useful addition
> to me.
> 
> This patch combines the main part of the previous qm importdisk and do_import
> into a helper $convert_and_attach in PVE/API2/Qemu.pm to avoid race conditions
> and potentially duplicating code from update_vm_api into do_import.
> Furthermore, the only other place where it was invoked was importovf, which now
> also uses the helper. importovf will be moved to PVE/API2/Qemu.pm, too, so
> placing the helper somewhere else does not look useful to me.
> 
> Signed-off-by: Dominic Jäger <d.jaeger at proxmox.com>
> ---
> v3->v4:
> * More detailed permissions
> * Solve race conditions and code reuse questions by completely moving do_import
>   to PVE/API2/Qemu.pm; lock the whole procedure
> * As I found both discussed approaches
>   - Image selector (Thomas)
>   - Paths (Dominik)
>   useful I included in both. Hope I didn't misunderstand any of you.
> 
> 
>  PVE/API2/Qemu.pm             | 184 ++++++++++++++++++++++++++++++++++-
>  PVE/CLI/qm.pm                |  70 ++-----------
>  PVE/QemuServer.pm            |   2 +-
>  PVE/QemuServer/Drive.pm      |  13 +++
>  PVE/QemuServer/ImportDisk.pm |  85 ----------------
>  PVE/QemuServer/Makefile      |   1 -
>  6 files changed, 205 insertions(+), 150 deletions(-)
>  delete mode 100755 PVE/QemuServer/ImportDisk.pm
> 
> diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm
> index 8da616a..9aa85b5 100644
> --- a/PVE/API2/Qemu.pm
> +++ b/PVE/API2/Qemu.pm
> @@ -45,8 +45,6 @@ BEGIN {
>      }
>  }
>  
> -use Data::Dumper; # fixme: remove
> -
>  use base qw(PVE::RESTHandler);
>  
>  my $opt_force_description = "Force physical removal. Without this, we simple remove the disk from the config file and create an additional configuration entry called 'unused[n]', which contains the volume ID. Unlink of unused[n] always cause physical removal.";
> @@ -4265,4 +4263,186 @@ __PACKAGE__->register_method({
>  	return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type});
>      }});
>  
> +# TODO Make locally scoped when importovf is moved from qm to API / this package

maybe we could have a description of $param here? doing that often helps 
to show whether this is a good choice ;) e.g., in this case 
$param->{original_source} is never used..

I would suggest moving the config loading outside and passing the 
configs to this. importovf will probably hold a lock and load the 
configs once, so no need to put this inside this shared method.

> +our $convert_and_attach_disk = sub {
> +    my ($param) = @_;
> +    my $vm_conf = PVE::QemuConfig->load_config($param->{vmid});
> +    my $store_conf = PVE::Storage::config();
> +    PVE::QemuConfig->check_lock($vm_conf) if !$param->{skiplock};
> +    if ($param->{device} && $vm_conf->{$param->{device}}) {
> +	die "Could not import because device $param->{device} is already in ".
> +	"use in VM $param->{vmid}. Choose a different device!\n";
> +    }
> +    if ($param->{digest} && $param->{digest} ne $vm_conf->{digest}) {
> +	die "VM $param->{vmid} config checksum missmatch (file change by other user?)\n";
> +    }

use assert_if_modified, but also move this to the API call where the 
config is locked+loaded..

> +
> +    my $msg = $param->{device} ? "to $param->{device} on" : 'as unused disk to';
> +    print "Importing disk '$param->{source_as_path}' $msg VM $param->{vmid}...\n";

this sounds strange, maybe Dylan can contribute a proper English phrasing
for this ;)

> +
> +    my $src_size = PVE::Storage::file_size_info($param->{source_as_path});

needs check for undef

> +    my $dst_format = PVE::QemuServer::resolve_dst_disk_format(
> +	$store_conf, $param->{storage}, undef, $param->{format});

isn't this a bit strange? if I request to import as qcow2, but my 
storage does not support it, I probably want to get an error and not 
silently be converted to the default format of the storage?

> +    my $dst_volid = PVE::Storage::vdisk_alloc($store_conf, $param->{storage},
> +	$param->{vmid}, $dst_format, undef, $src_size / 1024);
> +
> +    eval {
> +	local $SIG{INT} =
> +	local $SIG{TERM} =
> +	local $SIG{QUIT} =
> +	local $SIG{HUP} =
> +	local $SIG{PIPE} = sub { die "Interrupted by signal $!\n"; };
> +
> +	my $zeroinit = PVE::Storage::volume_has_feature($store_conf,
> +	    'sparseinit', $dst_volid);
> +
> +	PVE::Storage::activate_volumes($store_conf, [$dst_volid]);
> +	PVE::QemuServer::qemu_img_convert($param->{source_as_path}, $dst_volid,
> +	$src_size, undef, $zeroinit);
> +	PVE::Storage::deactivate_volumes($store_conf, [$dst_volid]);
> +
> +	if ($param->{device}) {
> +	    my $device_param = $dst_volid;
> +	    $device_param .= ",$param->{device_options}" if $param->{device_options};
> +	    $update_vm_api->({
> +		vmid => $param->{vmid},
> +		$param->{device} => $device_param,
> +		skiplock => $param->{skiplock} || 0, # Avoid uninitialized values
> +	    }, 1);
> +	} else {
> +	    $param->{device} = PVE::QemuConfig->add_unused_volume($vm_conf, $dst_volid);
> +	    PVE::QemuConfig->write_config($param->{vmid}, $vm_conf);
> +	}
> +    };
> +    if (my $err = $@) {
> +	eval { PVE::Storage::vdisk_free($store_conf, $dst_volid) };
> +	warn "Cleanup of $dst_volid failed: $@ \n" if $@;
> +
> +	die "Importing disk '$param->{source_as_path}' failed: $err\n" if $err;
> +    }
> +
> +    return $dst_volid;
> +};
> +
> +__PACKAGE__->register_method ({
> +    name => 'importdisk',
> +    path => '{vmid}/importdisk',
> +    method => 'POST',
> +    protected => 1, # for worker upid file
> +    proxyto => 'node',
> +    description => "Import an external disk image into a VM. The image format ".
> +	"has to be supported by qemu-img.",
> +    permissions => {
> +	check => [ 'and',
> +	    [ 'perm', '/storage/{storage}', ['Datastore.Audit']],
> +	    [ 'perm', '/storage/{storage}', ['Datastore.Allocate']],
> +	    [ 'perm', '/storage/{storage}', ['Datastore.AllocateTemplate']],
> +	    [ 'perm', '/storage/{storage}', ['Datastore.AllocateSpace']],
> +	    [ 'perm', '/vms/{vmid}', ['VM.Allocate']],
> +	    [ 'perm', '/vms/{vmid}', ['VM.Config.Disk']],

I doubt this is actually what you wanted here (all those permissions 
combined don't really make sense).

Let's see whether we can disentangle it a bit:
- we are modifying a VM config (adding a new disk entry or a new unused 
  entry) - this is should require the same permissions as modifying that 
  part of the config, which is probably VM.Config.Disk and 
  Datastore.AllocateSpace
- we are allocating a new vdisk volume on a storage (this should require 
  Datastore.Allocatespace)

we also potentially read from a storage (which requires a separate 
check) or from an arbitrary path (which requires yet another check).

> +	],
> +    },
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    node => get_standard_option('pve-node'),
> +	    vmid => get_standard_option('pve-vmid',
> +		{completion => \&PVE::QemuServer::complete_vmid}),
> +	    source => {
> +		description => "Disk image to import. Can be a volid ".
> +		    "(local-lvm:vm-104-disk-0), an image on a PVE storage ".
> +		    "(local:104/toImport.raw) or (for root only) an absolute ".
> +		    "path on the server.",
> +		type => 'string',

we have a format for something similar (volid or path) already, maybe we 
can extend it?

> +	    },
> +	    device => {
> +		type => 'string',
> +		description => "Bus/Device type of the new disk (e.g. 'ide0', ".
> +		    "'scsi2'). Will add the image as unused disk if omitted.",
> +		enum => [PVE::QemuServer::Drive::valid_drive_names()],
> +		optional => 1,
> +	    },
> +	    device_options => {
> +		type => 'string',
> +		format => 'drive_options',
> +		description => "Options to set for the new disk ".
> +		    "(e.g. 'discard=on,backup=0')",
> +		optional => 1,
> +	    },
> +	    storage => get_standard_option('pve-storage-id', {
> +		description => "The storage to which the image will be imported to.",
> +		completion => \&PVE::QemuServer::complete_storage,
> +	    }),
> +	    format => {
> +		type => 'string',
> +		description => 'Target format.',
> +		enum => [ 'raw', 'qcow2', 'vmdk' ],
> +		optional => 1,
> +	    },
> +	    digest => get_standard_option('pve-config-digest'),
> +	    skiplock => get_standard_option('skiplock'),
> +	},
> +    },
> +    returns => { type => 'string'},
> +    code => sub {
> +	my ($param) = @_;
> +	my $rpcenv = PVE::RPCEnvironment::get();
> +	my $authuser = $rpcenv->get_user();
> +
> +	my $vmid = extract_param($param, 'vmid');
> +	my $original_source = extract_param($param, 'source');
> +	my $digest = extract_param($param, 'digest');
> +	my $device_options = extract_param($param, 'device_options');
> +	my $device = extract_param($param, 'device');
> +	# importovf holds a lock itself which would make automatically updating
> +	# VM configs fail
> +	my $skiplock = extract_param($param, 'skiplock');
> +	my $storecfg = PVE::Storage::config();
> +
> +	if ($skiplock && $authuser ne 'root at pam') {
> +	    raise_perm_exc("Only root may use skiplock.");
> +	}
> +	if ($original_source eq "") {
> +	    die "Could not import because source parameter is an empty string!\n";
> +	}

should be handled by schema, not manually.

> +	if ($device && !PVE::QemuServer::is_valid_drivename($device)) {
> +	    die "Invalid device name: $device!";
> +	}

this should not be possible, since it's an enum and already checked by 
the API

> +	if ($device_options && !$device) {
> +	    die "Cannot use --device_options without specifying --device!"
> +	}

this can be encoded in the schema (with 'requires')

> +	eval {
> +	    PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg,
> +		$vmid, $original_source)
> +	};

so the STORAGE:PATH example from the description is also limited to 
root at pam

> +	raise_perm_exc($@) if $@;
> +
> +	# A path is required for $convert_and_attach_disk
> +	my $volid_as_path = eval { # Nonempty iff $original_source is a volid
> +	    PVE::Storage::path($storecfg, $original_source);
> +	};
> +	my $source_as_path = $volid_as_path || $original_source ;
> +	if (!-e $source_as_path) {
> +	    die "Could not import because source '$original_source' does not exist!\n";
> +	}
> +
> +	my $worker = sub {
> +	    my $dst_volid = PVE::QemuConfig->lock_config($vmid, $convert_and_attach_disk,
> +	    {
> +		vmid => $vmid,
> +		original_source => $original_source,
> +		device => $device,
> +		device_options => $device_options,
> +		storage => extract_param($param, 'storage'),
> +		source_as_path => $source_as_path,
> +		format => extract_param($param, 'format'),
> +		skiplock => $skiplock,
> +	    });
> +	    print "Successfully imported disk '$original_source ' as ".
> +		"$device: $dst_volid\n";

$device is potentially undef here

> +	};
> +
> +	return $rpcenv->fork_worker('importdisk', $vmid, $authuser, $worker);
> +    }});
> +
>  1;
> diff --git a/PVE/CLI/qm.pm b/PVE/CLI/qm.pm
> index 282fa86..f71b3d6 100755
> --- a/PVE/CLI/qm.pm
> +++ b/PVE/CLI/qm.pm
> @@ -31,7 +31,6 @@ use PVE::QemuConfig;
>  use PVE::QemuServer::Drive;
>  use PVE::QemuServer::Helpers;
>  use PVE::QemuServer::Agent qw(agent_available);
> -use PVE::QemuServer::ImportDisk;
>  use PVE::QemuServer::Monitor qw(mon_cmd);
>  use PVE::QemuServer::OVF;
>  use PVE::QemuServer;
> @@ -445,61 +444,6 @@ __PACKAGE__->register_method ({
>  	return undef;
>      }});
>  
> -__PACKAGE__->register_method ({
> -    name => 'importdisk',
> -    path => 'importdisk',
> -    method => 'POST',
> -    description => "Import an external disk image as an unused disk in a VM. The
> - image format has to be supported by qemu-img(1).",
> -    parameters => {
> -	additionalProperties => 0,
> -	properties => {
> -	    vmid => get_standard_option('pve-vmid', {completion => \&PVE::QemuServer::complete_vmid}),
> -	    source => {
> -		description => 'Path to the disk image to import',
> -		type => 'string',
> -		optional => 0,
> -	    },
> -            storage => get_standard_option('pve-storage-id', {
> -		description => 'Target storage ID',
> -		completion => \&PVE::QemuServer::complete_storage,
> -		optional => 0,
> -            }),
> -	    format => {
> -		type => 'string',
> -		description => 'Target format',
> -		enum => [ 'raw', 'qcow2', 'vmdk' ],
> -		optional => 1,
> -	    },
> -	},
> -    },
> -    returns => { type => 'null'},
> -    code => sub {
> -	my ($param) = @_;
> -
> -	my $vmid = extract_param($param, 'vmid');
> -	my $source = extract_param($param, 'source');
> -	my $storeid = extract_param($param, 'storage');
> -	my $format = extract_param($param, 'format');
> -
> -	my $vm_conf = PVE::QemuConfig->load_config($vmid);
> -	PVE::QemuConfig->check_lock($vm_conf);
> -	die "$source: non-existent or non-regular file\n" if (! -f $source);
> -
> -	my $storecfg = PVE::Storage::config();
> -	PVE::Storage::storage_check_enabled($storecfg, $storeid);
> -
> -	my $target_storage_config =  PVE::Storage::storage_config($storecfg, $storeid);
> -	die "storage $storeid does not support vm images\n"
> -	    if !$target_storage_config->{content}->{images};
> -
> -	print "importing disk '$source' to VM $vmid ...\n";
> -	my ($drive_id, $volid) = PVE::QemuServer::ImportDisk::do_import($source, $vmid, $storeid, { format => $format });
> -	print "Successfully imported disk as '$drive_id:$volid'\n";
> -
> -	return undef;
> -    }});
> -
>  __PACKAGE__->register_method ({
>      name => 'terminal',
>      path => 'terminal',
> @@ -640,17 +584,21 @@ __PACKAGE__->register_method ({
>  	$conf->{cores} = $parsed->{qm}->{cores} if defined($parsed->{qm}->{cores});
>  
>  	eval {
> -	    # order matters, as do_import() will load_config() internally
> +	    # order matters, as $convert_and_attach_disk will load_config() internally
>  	    $conf->{vmgenid} = PVE::QemuServer::generate_uuid();
>  	    $conf->{smbios1} = PVE::QemuServer::generate_smbios1_uuid();
>  	    PVE::QemuConfig->write_config($vmid, $conf);
>  
>  	    foreach my $disk (@{ $parsed->{disks} }) {
>  		my ($file, $drive) = ($disk->{backing_file}, $disk->{disk_address});
> -		PVE::QemuServer::ImportDisk::do_import($file, $vmid, $storeid, {
> -		    drive_name => $drive,
> +		$PVE::API2::Qemu::convert_and_attach_disk->({
> +		    node => $nodename,
> +		    vmid => $vmid,
> +		    source_as_path => $file,
> +		    storage => $storeid,
> +		    device => $drive,
>  		    format => $format,
> -		    skiplock => 1,
> +		    skiplock => 1, # Required to update VM configs
>  		});
>  	    }
>  
> @@ -984,7 +932,7 @@ our $cmddef = {
>  
>      terminal => [ __PACKAGE__, 'terminal', ['vmid']],
>  
> -    importdisk => [ __PACKAGE__, 'importdisk', ['vmid', 'source', 'storage']],
> +    importdisk => [ "PVE::API2::Qemu", 'importdisk', ['vmid', 'source', 'storage'], { node => $nodename }],
>  
>      importovf => [ __PACKAGE__, 'importovf', ['vmid', 'manifest', 'storage']],
>  
> diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm
> index 2747c66..715c507 100644
> --- a/PVE/QemuServer.pm
> +++ b/PVE/QemuServer.pm
> @@ -6613,7 +6613,7 @@ sub qemu_img_convert {
>  	$src_path = PVE::Storage::path($storecfg, $src_volid, $snapname);
>  	$src_is_iscsi = ($src_path =~ m|^iscsi://|);
>  	$cachemode = 'none' if $src_scfg->{type} eq 'zfspool';
> -    } elsif (-f $src_volid) {
> +    } elsif (-f $src_volid || -b _) { # -b for LVM images for example

this is not really related to this patch, right?

>  	$src_path = $src_volid;
>  	if ($src_path =~ m/\.($PVE::QemuServer::Drive::QEMU_FORMAT_RE)$/) {
>  	    $src_format = $1;
> diff --git a/PVE/QemuServer/Drive.pm b/PVE/QemuServer/Drive.pm
> index 91c33f8..af52035 100644
> --- a/PVE/QemuServer/Drive.pm
> +++ b/PVE/QemuServer/Drive.pm
> @@ -308,6 +308,19 @@ my $alldrive_fmt = {
>      %wwn_fmt,
>  };
>  
> +my %optional_file_drivedesc_base = %drivedesc_base;
> +$optional_file_drivedesc_base{file}{optional} = 1;
> +my $drive_options_fmt = {
> +    %optional_file_drivedesc_base,
> +    %iothread_fmt,
> +    %model_fmt,
> +    %queues_fmt,
> +    %scsiblock_fmt,
> +    %ssd_fmt,
> +    %wwn_fmt,
> +};
> +PVE::JSONSchema::register_format('drive_options', $drive_options_fmt);
> +
>  my $efidisk_fmt = {
>      volume => { alias => 'file' },
>      file => {
> diff --git a/PVE/QemuServer/ImportDisk.pm b/PVE/QemuServer/ImportDisk.pm
> deleted file mode 100755
> index 51ad52e..0000000
> --- a/PVE/QemuServer/ImportDisk.pm
> +++ /dev/null
> @@ -1,85 +0,0 @@
> -package PVE::QemuServer::ImportDisk;
> -
> -use strict;
> -use warnings;
> -
> -use PVE::Storage;
> -use PVE::QemuServer;
> -use PVE::Tools qw(run_command extract_param);
> -
> -# imports an external disk image to an existing VM
> -# and creates by default a drive entry unused[n] pointing to the created volume
> -# $params->{drive_name} may be used to specify ide0, scsi1, etc ...
> -# $params->{format} may be used to specify qcow2, raw, etc ...
> -sub do_import {
> -    my ($src_path, $vmid, $storage_id, $params) = @_;
> -
> -    my $drive_name = extract_param($params, 'drive_name');
> -    my $format = extract_param($params, 'format');
> -    if ($drive_name && !(PVE::QemuServer::is_valid_drivename($drive_name))) {
> -	die "invalid drive name: $drive_name\n";
> -    }
> -
> -    # get the needed size from  source disk
> -    my $src_size = PVE::Storage::file_size_info($src_path);
> -
> -    # get target format, target image's path, and whether it's possible to sparseinit
> -    my $storecfg = PVE::Storage::config();
> -    my $dst_format = PVE::QemuServer::resolve_dst_disk_format($storecfg, $storage_id, undef, $format);
> -
> -    my $dst_volid = PVE::Storage::vdisk_alloc($storecfg, $storage_id, $vmid, $dst_format, undef, $src_size / 1024);
> -
> -    my $zeroinit = PVE::Storage::volume_has_feature($storecfg, 'sparseinit', $dst_volid);
> -
> -    my $create_drive = sub {
> -	my $vm_conf = PVE::QemuConfig->load_config($vmid);
> -	if (!$params->{skiplock}) {
> -	    PVE::QemuConfig->check_lock($vm_conf);
> -	}
> -
> -	if ($drive_name) {
> -	    # should never happen as setting $drive_name is not exposed to public interface
> -	    die "cowardly refusing to overwrite existing entry: $drive_name\n" if $vm_conf->{$drive_name};
> -
> -	    my $modified = {}; # record what $option we modify
> -	    $modified->{$drive_name} = 1;
> -	    $vm_conf->{pending}->{$drive_name} = $dst_volid;
> -	    PVE::QemuConfig->write_config($vmid, $vm_conf);
> -
> -	    my $running = PVE::QemuServer::check_running($vmid);
> -	    if ($running) {
> -		my $errors = {};
> -		PVE::QemuServer::vmconfig_hotplug_pending($vmid, $vm_conf, $storecfg, $modified, $errors);
> -		warn "hotplugging imported disk '$_' failed: $errors->{$_}\n" for keys %$errors;
> -	    } else {
> -		PVE::QemuServer::vmconfig_apply_pending($vmid, $vm_conf, $storecfg);
> -	    }
> -	} else {
> -	    $drive_name = PVE::QemuConfig->add_unused_volume($vm_conf, $dst_volid);
> -	    PVE::QemuConfig->write_config($vmid, $vm_conf);
> -	}
> -    };
> -
> -    eval {
> -	# trap interrupts so we have a chance to clean up
> -	local $SIG{INT} =
> -	    local $SIG{TERM} =
> -	    local $SIG{QUIT} =
> -	    local $SIG{HUP} =
> -	    local $SIG{PIPE} = sub { die "interrupted by signal $!\n"; };
> -
> -	PVE::Storage::activate_volumes($storecfg, [$dst_volid]);
> -	PVE::QemuServer::qemu_img_convert($src_path, $dst_volid, $src_size, undef, $zeroinit);
> -	PVE::Storage::deactivate_volumes($storecfg, [$dst_volid]);
> -	PVE::QemuConfig->lock_config($vmid, $create_drive);
> -    };
> -    if (my $err = $@) {
> -	eval { PVE::Storage::vdisk_free($storecfg, $dst_volid) };
> -	warn "cleanup of $dst_volid failed: $@\n" if $@;
> -	die $err;
> -    }
> -
> -    return ($drive_name, $dst_volid);
> -}
> -
> -1;
> diff --git a/PVE/QemuServer/Makefile b/PVE/QemuServer/Makefile
> index fd8cfbb..ecdab56 100644
> --- a/PVE/QemuServer/Makefile
> +++ b/PVE/QemuServer/Makefile
> @@ -1,7 +1,6 @@
>  SOURCES=PCI.pm		\
>  	USB.pm		\
>  	Memory.pm	\
> -	ImportDisk.pm	\
>  	OVF.pm		\
>  	Cloudinit.pm	\
>  	Agent.pm	\
> -- 
> 2.20.1
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel at lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 





More information about the pve-devel mailing list