* [pve-devel] [PATCH qemu-server v3] Prepare API for import GUI
@ 2020-12-18 11:49 Dominic Jäger
2020-12-18 11:49 ` [pve-devel] [PATCH manager v3] gui: Add import for disk & VM Dominic Jäger
0 siblings, 1 reply; 2+ messages in thread
From: Dominic Jäger @ 2020-12-18 11:49 UTC (permalink / raw)
To: pve-devel
Move existing import functionality to the API so that it is available for the
GUI.
Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
I haven't checked everything (e.g. permissions, code style) yet, but
1. a large part of intermediate bloat is away (some may be left)
2. it's rebased
3. so clicking through the GUI works (again)
Therefore I wanted to send a short update before going on holiday.
PVE/API2/Qemu.pm | 258 ++++++++++++++++++++++++++++++++++-
PVE/CLI/qm.pm | 127 +++--------------
PVE/QemuServer.pm | 18 ++-
PVE/QemuServer/Drive.pm | 21 +++
PVE/QemuServer/ImportDisk.pm | 85 ------------
PVE/QemuServer/Makefile | 1 -
PVE/QemuServer/OVF.pm | 10 +-
7 files changed, 319 insertions(+), 201 deletions(-)
delete mode 100755 PVE/QemuServer/ImportDisk.pm
diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm
index e2d2d67..e6856e6 100644
--- a/PVE/API2/Qemu.pm
+++ b/PVE/API2/Qemu.pm
@@ -45,7 +45,6 @@ BEGIN {
}
}
-use Data::Dumper; # fixme: remove
use base qw(PVE::RESTHandler);
@@ -173,6 +172,28 @@ my $create_disks = sub {
push @$vollist, $volid;
delete $disk->{format}; # no longer needed
$res->{$ds} = PVE::QemuServer::print_drive($disk);
+ } elsif ($disk->{importsource}) {
+ # must be before $NEW_DISK_RE because $NEW_DISK_RE is matched in imports
+ # because the "magic" number of the volid is irrelevant and arbitrarily set to 0 so the API allows it
+ my $volid_as_path = eval { # Nonempty iff $original_source is a volid
+ PVE::Storage::path($storecfg, $disk->{importsource});
+ };
+ my $source_as_path = $volid_as_path || $disk->{importsource} ;
+ my $volid = $PVE::API2::Qemu::import_disk->({
+ vmid => $vmid,
+ original_source => $disk->{importsource},
+ device_options => "discard=on",
+ storage => (split(':', $disk->{file}))[0],
+ source_as_path => $source_as_path,
+ format => $disk->{format},
+ skiplock => 1,
+ }
+ );
+
+ delete $disk->{importsource};
+ $disk->{file} = $volid;
+ push @$vollist, $volid;
+ $res->{$ds} = PVE::QemuServer::print_drive($disk);
} elsif ($volid =~ $NEW_DISK_RE) {
my ($storeid, $size) = ($2 || $default_storage, $3);
die "no storage ID specified (and no default storage)\n" if !$storeid;
@@ -1281,6 +1302,19 @@ my $update_vm_api = sub {
if defined($conf->{pending}->{$opt});
&$create_disks($rpcenv, $authuser, $conf->{pending}, $arch, $storecfg, $vmid, undef, {$opt => $param->{$opt}});
+ } elsif ($param->{$opt} =~ m/importsource/) {
+ my $disk = $param->{$opt};
+ $disk =~ s/importsource=([^,]+),?//;
+ my $path = $1;
+ $disk =~ m/^(.+):0/;
+ my $storage = $1;
+ my $volid = $PVE::API2::Qemu::import_disk->({
+ vmid => $vmid,
+ source_as_path => $path,
+ storage => $storage,
+ });
+
+ $conf->{pending}->{$opt} = $volid;
} elsif ($opt =~ m/^serial\d+/) {
if ((!defined($conf->{$opt}) || $conf->{$opt} eq 'socket') && $param->{$opt} eq 'socket') {
$rpcenv->check_vm_perm($authuser, $vmid, undef, ['VM.Config.HWType']);
@@ -4320,4 +4354,226 @@ __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
+#
+# 2-step process:
+# 1. convert is qemu-img convert
+# 2. attach is update_vm_api
+#
+# vmid ... target VM ID
+# source_as_path ... absolute path of the source image (volid must be converted before)
+# device ... device/drive where the image will be attached (ide0, sata2, ...)
+# device_options ... options for attaching the device (discard=on,cache=unsafe, ...)
+# storage ... target storage for the disk image
+# format ... target format for the disk image
+# skiplock ... if skiploc during attach/upate_vm_api,
+our $import_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->{storage}) {
+ die "It is necessary to pass the storage parameter";
+ }
+ 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";
+ }
+
+ my $msg = $param->{device} ? "to $param->{device} on" : 'as unused disk to';
+ print "Importing disk '$param->{source_as_path}' $msg VM $param->{vmid}...\n";
+
+ my $src_size = PVE::Storage::file_size_info($param->{source_as_path});
+ if (!defined($src_size)) {
+ die "Could not get file size of $param->{source_as_path}";
+ } elsif (!$src_size) {
+ die "Size of file $param->{source_as_path} is 0";
+ } elsif ($src_size==1) {
+ die "Cannot import directory";
+ }
+
+ my $dst_format = PVE::QemuServer::resolve_dst_disk_format(
+ $store_conf, $param->{storage}, undef, $param->{format});
+ 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 (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']],
+ ],
+ },
+ 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',
+ },
+ 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 => 'null'},
+ 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@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";
+ }
+ if ($device && !PVE::QemuServer::is_valid_drivename($device)) {
+ die "Invalid device name: $device!";
+ }
+ if ($device_options && !$device) {
+ die "Cannot use --device_options without specifying --device!"
+ }
+
+ if ($original_source =~ m/^http/) {
+ die "implement me";
+ # my $tmpPath = '/tmp';
+ # PVE::Tools::run_command(['/usr/bin/wget', $original_source, '-P', $tmpPath]);
+ # $original_source =~ m!([^/]+)$!;
+ # my $filename = $tmpPath . '/' . $1;
+ # my $extractDir = $tmpPath .'/' . 'pve_importing';
+ # if ($filename =~ m/.zip$/) {
+ # PVE::Tools::run_command(['/usr/bin/unzip', $filename, '-d', $extractDir], outfunc => sub {
+ # my $line = shift;
+ # if ($line =~ m!\s*extracting:\s*(\S+)\s*$!) {
+ # my $extracted_file = $1;
+ # $original_source = $extracted_file; # only one file for the moment
+ # }
+
+ # });
+ # } else {
+ # die "Can only import .zip files from URLs";
+ # }
+ } else {
+ eval {
+ PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg,
+ $vmid, $original_source)
+ };
+ raise_perm_exc($@) if $@;
+ }
+
+ # A path is required for $import_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 $storeid = extract_param($param, 'storage');
+ my $format = extract_param($param, 'format');
+ my $conf = PVE::QemuConfig->load_config($vmid);
+
+ my $volid = "${storeid}:0";
+ if ($device_options) {
+ $volid .= ",${device_options}";
+ }
+ $volid .= ",importsource=${source_as_path}";
+ if ($device) {
+ $update_vm_api->({
+ node => "dev",
+ vmid => $vmid,
+ $device => $volid,
+ });
+ } else {
+ $device = PVE::QemuConfig->add_unused_volume($conf, $volid);
+ $update_vm_api->({
+ node => "dev",
+ vmid => $vmid,
+ $device => $volid,
+ });
+ }
+ return;
+ }});
+
1;
diff --git a/PVE/CLI/qm.pm b/PVE/CLI/qm.pm
index b9b6051..56e57ea 100755
--- a/PVE/CLI/qm.pm
+++ b/PVE/CLI/qm.pm
@@ -27,11 +27,12 @@ use PVE::Tools qw(extract_param);
use PVE::API2::Qemu::Agent;
use PVE::API2::Qemu;
+use PVE::API2::Nodes;
+use PVE::Storage::Plugin;
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;
@@ -440,61 +441,6 @@ __PACKAGE__->register_method ({
return;
}});
-__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;
- }});
-
__PACKAGE__->register_method ({
name => 'terminal',
path => 'terminal',
@@ -612,63 +558,26 @@ __PACKAGE__->register_method ({
my $format = PVE::Tools::extract_param($param, 'format');
my $dryrun = PVE::Tools::extract_param($param, 'dryrun');
- die "$ovf_file: non-existent or non-regular file\n" if (! -f $ovf_file);
- my $storecfg = PVE::Storage::config();
- PVE::Storage::storage_check_enabled($storecfg, $storeid);
-
- my $parsed = PVE::QemuServer::OVF::parse_ovf($ovf_file);
-
- if ($dryrun) {
- print to_json($parsed, { pretty => 1, canonical => 1});
- return;
+ my $all_storages = PVE::Storage::config();
+ PVE::Storage::storage_check_enabled($all_storages, $storeid);
+ if ($format) {
+ my $target_storage_config = PVE::Storage::storage_config($all_storages, $storeid);
+ my (undef, $valid_formats) = PVE::Storage::Plugin::default_format($target_storage_config);
+ if (!grep( /^$format$/, @$valid_formats)) {
+ die "Format $format is not supported in storage $storeid";
+ }
}
- eval { PVE::QemuConfig->create_and_lock_config($vmid) };
- die "Reserving empty config for OVF import to VM $vmid failed: $@" if $@;
-
- my $conf = PVE::QemuConfig->load_config($vmid);
- die "Internal error: Expected 'create' lock in config of VM $vmid!"
- if !PVE::QemuConfig->has_lock($conf, "create");
-
- $conf->{name} = $parsed->{qm}->{name} if defined($parsed->{qm}->{name});
- $conf->{memory} = $parsed->{qm}->{memory} if defined($parsed->{qm}->{memory});
- $conf->{cores} = $parsed->{qm}->{cores} if defined($parsed->{qm}->{cores});
-
- eval {
- # order matters, as do_import() 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,
- format => $format,
- skiplock => 1,
- });
+ my $parsed = PVE::API2::Nodes::Nodeinfo->readovf({node=>"dev", manifest=> $ovf_file});
+ delete $parsed->{digest};
+ foreach my $key (keys %$parsed) {
+ if (PVE::QemuServer::is_valid_drivename($key)) {
+ $parsed->{$key} = "$storeid:0,$parsed->{$key}";
}
-
- # reload after disks entries have been created
- $conf = PVE::QemuConfig->load_config($vmid);
- my $devs = PVE::QemuServer::get_default_bootdevices($conf);
- $conf->{boot} = PVE::QemuServer::print_bootorder($devs);
- PVE::QemuConfig->write_config($vmid, $conf);
- };
-
- my $err = $@;
- if ($err) {
- my $skiplock = 1;
- # eval for additional safety in error path
- eval { PVE::QemuServer::destroy_vm($storecfg, $vmid, $skiplock) };
- warn "Could not destroy VM $vmid: $@" if "$@";
- die "import failed - $err";
}
-
- PVE::QemuConfig->remove_lock($vmid, "create");
-
+ my $config = {%$parsed, node=>"dev", vmid=>$vmid};
+ PVE::API2::Qemu->create_vm($config);
return;
-
}
});
@@ -979,7 +888,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 43b11c3..02202b4 100644
--- a/PVE/QemuServer.pm
+++ b/PVE/QemuServer.pm
@@ -998,6 +998,22 @@ sub verify_volume_id_or_qm_path {
return $volid;
}
+PVE::JSONSchema::register_format('pve-volume-id-or-absolute-path', \&verify_volume_id_or_absolute_path);
+sub verify_volume_id_or_absolute_path {
+ my ($volid, $noerr) = @_;
+
+ if ($volid =~ m|^/|) {
+ return $volid;
+ }
+
+ $volid = eval { PVE::JSONSchema::check_format('pve-volume-id', $volid, '') };
+ if ($@) {
+ return undef if $noerr;
+ die $@;
+ }
+ return $volid;
+}
+
my $usb_fmt = {
host => {
default_key => 1,
@@ -6658,7 +6674,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
$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 d560937..5850e92 100644
--- a/PVE/QemuServer/Drive.pm
+++ b/PVE/QemuServer/Drive.pm
@@ -145,6 +145,13 @@ my %drivedesc_base = (
verbose_description => "Mark this locally-managed volume as available on all nodes.\n\nWARNING: This option does not share the volume automatically, it assumes it is shared already!",
optional => 1,
default => 0,
+ },
+ importsource => {
+ type => 'string',
+ format => 'pve-volume-id-or-absolute-path',
+ format_description => 'Absolute path or volid',
+ description => 'Source to import the disk',
+ optional => 1,
}
);
@@ -308,6 +315,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 => {
@@ -435,6 +455,7 @@ sub parse_drive {
warn "invalid drive key: $key\n";
return;
}
+ use Data::Dumper;
my $desc = $drivedesc_hash->{$key}->{format};
my $res = eval { PVE::JSONSchema::parse_property_string($desc, $data) };
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 e4ed184..7a8a38f 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 \
diff --git a/PVE/QemuServer/OVF.pm b/PVE/QemuServer/OVF.pm
index c76c199..36b7fff 100644
--- a/PVE/QemuServer/OVF.pm
+++ b/PVE/QemuServer/OVF.pm
@@ -87,7 +87,7 @@ sub id_to_pve {
# returns two references, $qm which holds qm.conf style key/values, and \@disks
sub parse_ovf {
- my ($ovf, $debug) = @_;
+ my ($ovf, $debug, $ignore_size) = @_;
my $dom = XML::LibXML->load_xml(location => $ovf, no_blanks => 1);
@@ -220,9 +220,11 @@ ovf:Item[rasd:InstanceID='%s']/rasd:ResourceType", $controller_id);
die "error parsing $filepath, file seems not to exist at $backing_file_path\n";
}
- my $virtual_size;
- if ( !($virtual_size = PVE::Storage::file_size_info($backing_file_path)) ) {
- die "error parsing $backing_file_path, size seems to be $virtual_size\n";
+ my $virtual_size = 0;
+ if (!$ignore_size) { # Not possible if manifest is uploaded in web gui
+ if ( !($virtual_size = PVE::Storage::file_size_info($backing_file_path)) ) {
+ die "error parsing $backing_file_path: Could not get file size info: $@\n";
+ }
}
$pve_disk = {
--
2.20.1
^ permalink raw reply [flat|nested] 2+ messages in thread
* [pve-devel] [PATCH manager v3] gui: Add import for disk & VM
2020-12-18 11:49 [pve-devel] [PATCH qemu-server v3] Prepare API for import GUI Dominic Jäger
@ 2020-12-18 11:49 ` Dominic Jäger
0 siblings, 0 replies; 2+ messages in thread
From: Dominic Jäger @ 2020-12-18 11:49 UTC (permalink / raw)
To: pve-devel
Add GUI wizard to import whole VMs and a window for only single disks in
hardware view.
Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
PVE/API2/Nodes.pm | 49 ++++
www/manager6/Makefile | 2 +
www/manager6/Workspace.js | 15 ++
www/manager6/form/ControllerSelector.js | 15 ++
| 12 +
www/manager6/qemu/HDEdit.js | 197 ++++++++++++--
www/manager6/qemu/HardwareView.js | 33 +++
www/manager6/qemu/ImportWizard.js | 343 ++++++++++++++++++++++++
www/manager6/qemu/MultiHDEdit.js | 278 +++++++++++++++++++
www/manager6/window/Wizard.js | 2 +
10 files changed, 929 insertions(+), 17 deletions(-)
create mode 100644 www/manager6/qemu/ImportWizard.js
create mode 100644 www/manager6/qemu/MultiHDEdit.js
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 8172231e..1eb955f3 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -27,6 +27,7 @@ use PVE::HA::Env::PVE2;
use PVE::HA::Config;
use PVE::QemuConfig;
use PVE::QemuServer;
+use PVE::QemuServer::OVF;
use PVE::API2::Subscription;
use PVE::API2::Services;
use PVE::API2::Network;
@@ -224,6 +225,7 @@ __PACKAGE__->register_method ({
{ name => 'subscription' },
{ name => 'report' },
{ name => 'tasks' },
+ { name => 'readovf' },
{ name => 'rrd' }, # fixme: remove?
{ name => 'rrddata' },# fixme: remove?
{ name => 'replication' },
@@ -2173,6 +2175,53 @@ __PACKAGE__->register_method ({
return undef;
}});
+__PACKAGE__->register_method ({
+ name => 'readovf',
+ path => 'readovf',
+ method => 'GET',
+ protected => 1, # for worker upid file
+ proxyto => 'node',
+ description => "Read an .ovf manifest.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ manifest => {
+ description => ".ovf manifest",
+ type => 'string',
+ },
+ },
+ },
+ returns => {
+ description => "VM config according to .ovf manifest and digest of manifest",
+ type => "object",
+ properties => PVE::QemuServer::json_config_properties({
+ digest => {
+ type => 'string',
+ description => 'SHA1 digest of configuration file. This can be used to prevent concurrent modifications.',
+ },
+ }),
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $manifest = $param->{manifest};
+ die "$manifest: non-existent or non-regular file\n" if (! -f $manifest);
+
+ my $parsed = PVE::QemuServer::OVF::parse_ovf($manifest, 0, 1);
+ my $result;
+ $result->{digest} = Digest::SHA::sha1_hex($manifest);
+ $result->{cores} = $parsed->{qm}->{cores};
+ $result->{name} = $parsed->{qm}->{name};
+ $result->{memory} = $parsed->{qm}->{memory};
+
+ my $disks = $parsed->{disks};
+ foreach my $disk (@$disks) {
+ $result->{$disk->{disk_address}} = "importsource=".$disk->{backing_file};
+ }
+ return $result;
+}});
+
# bash completion helper
sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 85f90ecd..2969ed19 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -196,8 +196,10 @@ JSSRC= \
qemu/CmdMenu.js \
qemu/Config.js \
qemu/CreateWizard.js \
+ qemu/ImportWizard.js \
qemu/DisplayEdit.js \
qemu/HDEdit.js \
+ qemu/MultiHDEdit.js \
qemu/HDEfi.js \
qemu/HDMove.js \
qemu/HDResize.js \
diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 3d0f3fec..6ee7c957 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -282,11 +282,25 @@ Ext.define('PVE.StdWorkspace', {
}
});
+ var importVM = Ext.createWidget('button', {
+ pack: 'end',
+ margin: '3 5 0 0',
+ baseCls: 'x-btn',
+ iconCls: 'fa fa-desktop',
+ text: gettext("Import VM"),
+ disabled: !caps.vms['VM.Allocate'],
+ handler: function() {
+ var wiz = Ext.create('PVE.qemu.ImportWizard', {});
+ wiz.show();
+ }
+ });
+
sprovider.on('statechange', function(sp, key, value) {
if (key === 'GuiCap' && value) {
caps = value;
createVM.setDisabled(!caps.vms['VM.Allocate']);
createCT.setDisabled(!caps.vms['VM.Allocate']);
+ importVM.setDisabled(!caps.vms['VM.Allocate']);
}
});
@@ -334,6 +348,7 @@ Ext.define('PVE.StdWorkspace', {
},
createVM,
createCT,
+ importVM,
{
pack: 'end',
margin: '0 5 0 0',
diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js
index 9fdae5d1..d64fb4a3 100644
--- a/www/manager6/form/ControllerSelector.js
+++ b/www/manager6/form/ControllerSelector.js
@@ -68,6 +68,21 @@ clist_loop:
deviceid.validate();
},
+ getValues: function() {
+ return this.query('field').map(x => x.getValue());
+ },
+
+ getValuesAsString: function() {
+ return this.getValues().join('');
+ },
+
+ setValue: function(value) {
+ let regex = /([a-z]+)(\d+)/;
+ [_, controller, deviceid] = regex.exec(value);
+ this.down('field[name=controller]').setValue(controller);
+ this.down('field[name=deviceid]').setValue(deviceid);
+ },
+
initComponent: function() {
var me = this;
--git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js
index f718f69a..9bc27155 100644
--- a/www/manager6/node/CmdMenu.js
+++ b/www/manager6/node/CmdMenu.js
@@ -29,6 +29,18 @@ Ext.define('PVE.node.CmdMenu', {
wiz.show();
}
},
+ {
+ text: gettext("Import VM"),
+ itemId: 'importvm',
+ iconCls: 'fa fa-cube',
+ handler: function() {
+ var me = this.up('menu');
+ var wiz = Ext.create('PVE.qemu.ImportWizard', {
+ nodename: me.nodename
+ });
+ wiz.show();
+ }
+ },
{ xtype: 'menuseparator' },
{
text: gettext('Bulk Start'),
diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js
index e2a5b914..1403b67c 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -8,6 +8,10 @@ Ext.define('PVE.qemu.HDInputPanel', {
unused: false, // ADD usused disk imaged
+ showSourcePathTextfield: false, // to import a disk from an aritrary path
+
+ returnSingleKey: true, // {vmid}/importdisk expects multiple keys => false
+
vmconfig: {}, // used to select usused disks
viewModel: {},
@@ -58,6 +62,29 @@ Ext.define('PVE.qemu.HDInputPanel', {
}
},
+ /*
+ All radiofields (esp. sourceRadioPath and sourceRadioStorage) have the
+ same scope for name. But we need a different scope for each HDInputPanel in
+ a MultiHDInputPanel to get the selectionf or each HDInputPanel => Make
+ names so that those in one HDInputPanel are equal but different from other
+ HDInputPanels
+ */
+ getSourceTypeIdentifier() {
+ return 'sourceType_' + this.id;
+ },
+
+ // values ... the values from onGetValues
+ getSourceValue: function(values) {
+ let result;
+ let type = values[this.getSourceTypeIdentifier()];
+ if (type === 'storage') {
+ result = values.sourceVolid;
+ } else {
+ result = values.sourcePath;
+ }
+ return result;
+ },
+
onGetValues: function(values) {
var me = this;
@@ -68,14 +95,17 @@ Ext.define('PVE.qemu.HDInputPanel', {
me.drive.file = me.vmconfig[values.unusedId];
confid = values.controller + values.deviceid;
} else if (me.isCreate) {
+ // disk format & size should not be part of propertyString for import
if (values.hdimage) {
me.drive.file = values.hdimage;
+ } else if (me.isImport) {
+ me.drive.file = `${values.hdstorage}:0`; // so that API allows it
} else {
me.drive.file = values.hdstorage + ":" + values.disksize;
}
me.drive.format = values.diskformat;
}
-
+
PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
@@ -83,16 +113,29 @@ Ext.define('PVE.qemu.HDInputPanel', {
PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
- var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
- Ext.Array.each(names, function(name) {
- var burst_name = name + '_max';
+ var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
+ Ext.Array.each(names, function(name) {
+ var burst_name = name + '_max';
PVE.Utils.propertyStringSet(me.drive, values[name], name);
PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
- });
-
-
- params[confid] = PVE.Parser.printQemuDrive(me.drive);
+ });
+ if (me.returnSingleKey) {
+ if (me.isImport) {
+ me.drive.importsource = this.getSourceValue(values);
+ }
+ params[confid] = PVE.Parser.printQemuDrive(me.drive);
+ } else {
+ delete me.drive.file; // TODO make this nesting mess less
+ delete me.drive.format;
+ params.device_options = PVE.Parser.printPropertyString(me.drive);
+ params.source = this.getSourceValue(values);
+ params.device = values.controller + values.deviceid;
+ params.storage = values.hdstorage;
+ if (values.diskformat) {
+ params.format = values.diskformat;
+ }
+ }
return params;
},
@@ -149,10 +192,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
me.setValues(values);
},
+ getDevice: function() {
+ return this.bussel.getValuesAsString();
+ },
+
setNodename: function(nodename) {
var me = this;
me.down('#hdstorage').setNodename(nodename);
me.down('#hdimage').setStorage(undefined, nodename);
+ // me.down('#sourceStorageSelector').setNodename(nodename);
+ // me.down('#sourceFileSelector').setNodename(nodename);
},
initComponent : function() {
@@ -168,11 +217,18 @@ Ext.define('PVE.qemu.HDInputPanel', {
me.advancedColumn1 = [];
me.advancedColumn2 = [];
+
+ let nodename = me.nodename;
if (!me.confid || me.unused) {
+ let controllerColumn = me.showSourcePathTextfield ? me.column2 : me.column1;
me.bussel = Ext.create('PVE.form.ControllerSelector', {
+ itemId: 'bussel',
vmconfig: me.insideWizard ? {ide2: 'cdrom'} : {}
});
- me.column1.push(me.bussel);
+ if (me.showSourcePathTextfield) {
+ me.bussel.fieldLabel = 'Target Device';
+ }
+ controllerColumn.push(me.bussel);
me.scsiController = Ext.create('Ext.form.field.Display', {
fieldLabel: gettext('SCSI Controller'),
@@ -184,7 +240,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
submitValue: false,
hidden: true
});
- me.column1.push(me.scsiController);
+ controllerColumn.push(me.scsiController);
}
if (me.unused) {
@@ -199,14 +255,21 @@ Ext.define('PVE.qemu.HDInputPanel', {
allowBlank: false
});
me.column1.push(me.unusedDisks);
- } else if (me.isCreate) {
- me.column1.push({
+ } else if (me.isCreate || me.showSourcePathTextfield) {
+ let selector = {
xtype: 'pveDiskStorageSelector',
storageContent: 'images',
name: 'disk',
- nodename: me.nodename,
- autoSelect: me.insideWizard
- });
+ nodename: nodename,
+ hideSize: me.showSourcePathTextfield,
+ autoSelect: me.insideWizard || me.showSourcePathTextfield,
+ };
+ if (me.showSourcePathTextfield) {
+ selector.storageLabel = gettext('Target storage');
+ me.column2.push(selector);
+ } else {
+ me.column1.push(selector);
+ }
} else {
me.column1.push({
xtype: 'textfield',
@@ -217,6 +280,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
});
}
+ if (me.showSourcePathTextfield) {
+ me.column2.push({
+ xtype: 'box',
+ autoEl: { tag: 'hr' },
+ });
+ }
me.column2.push(
{
xtype: 'CacheTypeSelector',
@@ -231,6 +300,90 @@ Ext.define('PVE.qemu.HDInputPanel', {
name: 'discard'
}
);
+ if (me.showSourcePathTextfield) {
+ let show = (element, value) => {
+ element.setHidden(!value);
+ element.setDisabled(!value);
+ };
+
+ me.column1.unshift(
+ {
+ xtype: 'radiofield',
+ itemId: 'sourceRadioStorage',
+ name: me.getSourceTypeIdentifier(),
+ inputValue: 'storage',
+ boxLabel: gettext('Use a storage as source'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ checked: true,
+ listeners: {
+ change: (_, newValue) => {
+ let storageSelectors = [
+ me.down('#sourceStorageSelector'),
+ me.down('#sourceFileSelector'),
+ ];
+ for (const selector of storageSelectors) {
+ show(selector, newValue);
+ }
+ },
+ },
+ }, {
+ xtype: 'pveStorageSelector',
+ itemId: 'sourceStorageSelector',
+ name: 'inputImageStorage',
+ nodename: nodename,
+ fieldLabel: gettext('Source Storage'),
+ storageContent: 'images',
+ autoSelect: me.insideWizard,
+ hidden: true,
+ disabled: true,
+ listeners: {
+ change: function (_, selectedStorage) {
+ me.down('#sourceFileSelector').setStorage(selectedStorage);
+ },
+ },
+ }, {
+ xtype: 'pveFileSelector',
+ itemId: 'sourceFileSelector',
+ name: 'sourceVolid',
+ nodename: nodename,
+ storageContent: 'images',
+ hidden: true,
+ disabled: true,
+ fieldLabel: gettext('Source Image'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext("Place your source images into a new folder <storageRoot>/images/<newVMID>, for example /var/lib/vz/images/999"),
+ },
+ }, {
+ xtype: 'radiofield',
+ itemId: 'sourceRadioPath',
+ name: me.getSourceTypeIdentifier(),
+ inputValue: 'path',
+ boxLabel: gettext('Use an absolute path as source'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ listeners: {
+ change: (_, newValue) => {
+ show(me.down('#sourcePathTextfield'), newValue);
+ },
+ },
+ }, {
+ xtype: 'textfield',
+ itemId: 'sourcePathTextfield',
+ fieldLabel: gettext('Source Path'),
+ name: 'sourcePath',
+ autoEl: {
+ tag: 'div',
+ // 'data-qtip': gettext('Absolute path or URL to the source disk image, for example: /home/user/somedisk.qcow2, http://example.com/WindowsImage.zip'),
+ 'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'),
+ },
+ hidden: true,
+ disabled: true,
+ validator: (insertedText) =>
+ insertedText.startsWith('/') || insertedText.startsWith('http') ||
+ gettext('Must be an absolute path or URL'),
+ },
+ );
+ }
me.advancedColumn1.push(
{
@@ -372,14 +525,21 @@ Ext.define('PVE.qemu.HDEdit', {
confid: me.confid,
nodename: nodename,
unused: unused,
- isCreate: me.isCreate
+ isCreate: me.isCreate,
+ showSourcePathTextfield: me.isImport,
+ isImport: me.isImport,
+ returnSingleKey: !me.isImport,
});
var subject;
if (unused) {
me.subject = gettext('Unused Disk');
+ } else if (me.isImport) {
+ me.subject = gettext('Import Disk');
+ me.submitText = 'Import';
+ me.backgroundDelay = undefined;
} else if (me.isCreate) {
- me.subject = gettext('Hard Disk');
+ me.subject = gettext('Hard Disk');
} else {
me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
}
@@ -404,6 +564,9 @@ Ext.define('PVE.qemu.HDEdit', {
ipanel.setDrive(drive);
me.isValid(); // trigger validation
}
+ if (me.isImport) {
+ me.url = me.url.replace(/\/config$/, "/importdisk");
+ }
}
});
}
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 40b3fe86..f74e04fe 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -436,6 +436,38 @@ Ext.define('PVE.qemu.HardwareView', {
handler: run_move
});
+ var import_btn = new Proxmox.button.Button({
+ text: gettext('Import disk'),
+ hidden: !(
+ caps.storage['Datastore.Audit'] &&
+ caps.storage['Datastore.Allocate'] &&
+ caps.storage['Datastore.AllocateTemplate'] &&
+ caps.storage['Datastore.AllocateSpace'] &&
+ caps.vms['VM.Allocate'] &&
+ caps.vms['VM.Config.Disk'] &&
+ true
+ ),
+ handler: function() {
+ var win = Ext.create('PVE.qemu.HDEdit', {
+ method: 'POST',
+ url: `/api2/extjs/${baseurl}`,
+ pveSelNode: me.pveSelNode,
+ isImport: true,
+ listeners: {
+ add: function(_, component) {
+ component.down('#sourceRadioStorage').setValue(true);
+ component.down('#sourceStorageSelector').setHidden(false);
+ component.down('#sourceFileSelector').setHidden(false);
+ component.down('#sourceFileSelector').enable();
+ component.down('#sourceStorageSelector').enable();
+ },
+ },
+ });
+ win.on('destroy', me.reload, me);
+ win.show();
+ },
+ });
+
var remove_btn = new Proxmox.button.Button({
text: gettext('Remove'),
defaultText: gettext('Remove'),
@@ -752,6 +784,7 @@ Ext.define('PVE.qemu.HardwareView', {
edit_btn,
resize_btn,
move_btn,
+ import_btn,
revert_btn
],
rows: rows,
diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js
new file mode 100644
index 00000000..34a0fa53
--- /dev/null
+++ b/www/manager6/qemu/ImportWizard.js
@@ -0,0 +1,343 @@
+/*jslint confusion: true*/
+Ext.define('PVE.qemu.ImportWizard', {
+ extend: 'PVE.window.Wizard',
+ alias: 'widget.pveQemuImportWizard',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ viewModel: {
+ data: {
+ nodename: '',
+ current: {
+ scsihw: '' // TODO there is some error with apply after render_scsihw??
+ }
+ }
+ },
+
+ cbindData: {
+ nodename: undefined
+ },
+
+ subject: gettext('Import Virtual Machine'),
+
+ isImport: true,
+
+ addDiskFunction: function () {
+ let me = this;
+ let wizard;
+ if (me.xtype === 'button') {
+ wizard = me.up('window');
+ } else if (me.xtype === 'pveQemuImportWizard') {
+ wizard = me;
+ }
+ let multihd = wizard.down('pveQemuMultiHDInputPanel');
+ multihd.addDiskFunction();
+ },
+
+ items: [
+ {
+ xtype: 'inputpanel',
+ title: gettext('Import'),
+ itemId: 'importInputpanel',
+ column1: [
+ {
+ xtype: 'pveNodeSelector',
+ name: 'nodename',
+ cbind: {
+ selectCurNode: '{!nodename}',
+ preferredValue: '{nodename}'
+ },
+ bind: {
+ value: '{nodename}'
+ },
+ fieldLabel: gettext('Node'),
+ allowBlank: false,
+ onlineValidator: true
+ }, {
+ xtype: 'pveGuestIDSelector',
+ name: 'vmid',
+ guestType: 'qemu',
+ value: '',
+ loadNextFreeID: true,
+ validateExists: false
+ },
+ ],
+ column2: [
+ // { // TODO implement the rest
+ // xtype: 'filebutton',
+ // text: gettext('Load local manifest ...'),
+ // allowBlank: true,
+ // hidden: Proxmox.UserName !== 'root@pam',
+ // disabled: Proxmox.UserName !== 'root@pam',
+ // listeners: {
+ // change: (button,event,) => {
+ // var reader = new FileReader();
+ // let wizard = button.up('window');
+ // reader.onload = (e) => {
+ // let uploaded_ovf = e.target.result;
+ // // TODO set fields here
+ // // TODO When to upload disks to server?
+ // };
+ // reader.readAsText(event.target.files[0]);
+ // button.disable(); // TODO implement complete reload
+ // wizard.down('#successTextfield').show();
+ // }
+ // }
+ // },
+ {
+ xtype: 'label',
+ itemId: 'successTextfield',
+ hidden: true,
+ html: gettext('Manifest successfully uploaded'),
+ margin: '0 0 0 10',
+ },
+ {
+ xtype: 'textfield',
+ itemId: 'server_ovf_manifest',
+ name: 'ovf_textfield',
+ emptyText: '/mnt/nfs/exported.ovf',
+ fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
+ },
+ {
+ xtype: 'proxmoxButton',
+ text: gettext('Load remote manifest'),
+ handler: function() {
+ let inputpanel = this.up('#importInputpanel');
+ let nodename = inputpanel.down('pveNodeSelector').getValue();
+ // independent of onGetValues(), so that value of
+ // ovf_textfield can be removed for submit
+ let ovf_textfield_value = inputpanel.down('textfield[name=ovf_textfield]').getValue();
+ let wizard = this.up('window');
+ Proxmox.Utils.API2Request({
+ url: '/nodes/' + nodename + '/readovf',
+ method: 'GET',
+ params: {
+ manifest: ovf_textfield_value,
+ },
+ success: function(response){
+ let ovfdata = response.result.data;
+ wizard.down('#vmNameTextfield').setValue(ovfdata.name);
+ wizard.down('#cpupanel').getViewModel().set('coreCount', ovfdata.cores);
+ wizard.down('#memorypanel').down('pveMemoryField').setValue(ovfdata.memory);
+ delete ovfdata.name;
+ delete ovfdata.cores;
+ delete ovfdata.memory;
+ delete ovfdata.digest;
+ let devices = Object.keys(ovfdata); // e.g. ide0, sata2
+ let multihd = wizard.down('pveQemuMultiHDInputPanel');
+ if (devices.length > 0) {
+ multihd.removeAllDisks();
+ }
+ for (var device of devices) {
+ let path = ovfdata[device].split('=')[1];
+ multihd.addDiskFunction(device, path);
+ }
+ },
+ failure: function(response, opts) {
+ console.warn("Failure of load manifest button");
+ console.warn(response);
+ },
+ });
+
+ },
+ },
+ ],
+ onGetValues: function(values) {
+ delete values.server_ovf_manifest;
+ delete values.ovf_textfield;
+ return values;
+ }
+ },
+ {
+ xtype: 'inputpanel',
+ title: gettext('General'),
+ onlineHelp: 'qm_general_settings',
+ column1: [
+ {
+ xtype: 'textfield',
+ name: 'name',
+ itemId: 'vmNameTextfield',
+ vtype: 'DnsName',
+ value: '',
+ fieldLabel: gettext('Name'),
+ allowBlank: true,
+ }
+ ],
+ column2: [
+ {
+ xtype: 'pvePoolSelector',
+ fieldLabel: gettext('Resource Pool'),
+ name: 'pool',
+ value: '',
+ allowBlank: true
+ }
+ ],
+ advancedColumn1: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'onboot',
+ uncheckedValue: 0,
+ defaultValue: 0,
+ deleteDefaultValue: true,
+ fieldLabel: gettext('Start at boot')
+ }
+ ],
+ advancedColumn2: [
+ {
+ xtype: 'textfield',
+ name: 'order',
+ defaultValue: '',
+ emptyText: 'any',
+ labelWidth: 120,
+ fieldLabel: gettext('Start/Shutdown order')
+ },
+ {
+ xtype: 'textfield',
+ name: 'up',
+ defaultValue: '',
+ emptyText: 'default',
+ labelWidth: 120,
+ fieldLabel: gettext('Startup delay')
+ },
+ {
+ xtype: 'textfield',
+ name: 'down',
+ defaultValue: '',
+ emptyText: 'default',
+ labelWidth: 120,
+ fieldLabel: gettext('Shutdown timeout')
+ }
+ ],
+ onGetValues: function(values) {
+
+ ['name', 'pool', 'onboot', 'agent'].forEach(function(field) {
+ if (!values[field]) {
+ delete values[field];
+ }
+ });
+
+ var res = PVE.Parser.printStartup({
+ order: values.order,
+ up: values.up,
+ down: values.down
+ });
+
+ if (res) {
+ values.startup = res;
+ }
+
+ delete values.order;
+ delete values.up;
+ delete values.down;
+
+ return values;
+ }
+ },
+ {
+ xtype: 'pveQemuSystemPanel',
+ title: gettext('System'),
+ isCreate: true,
+ insideWizard: true
+ },
+ {
+ xtype: 'pveQemuMultiHDInputPanel',
+ title: gettext('Hard Disk'),
+ bind: {
+ nodename: '{nodename}',
+ },
+ isCreate: true,
+ insideWizard: true,
+ },
+ {
+ itemId: 'cpupanel',
+ xtype: 'pveQemuProcessorPanel',
+ insideWizard: true,
+ title: gettext('CPU')
+ },
+ {
+ itemId: 'memorypanel',
+ xtype: 'pveQemuMemoryPanel',
+ insideWizard: true,
+ title: gettext('Memory')
+ },
+ {
+ xtype: 'pveQemuNetworkInputPanel',
+ bind: {
+ nodename: '{nodename}'
+ },
+ title: gettext('Network'),
+ insideWizard: true
+ },
+ {
+ title: gettext('Confirm'),
+ layout: 'fit',
+ items: [
+ {
+ xtype: 'grid',
+ store: {
+ model: 'KeyValue',
+ sorters: [{
+ property : 'key',
+ direction: 'ASC'
+ }]
+ },
+ columns: [
+ {header: 'Key', width: 150, dataIndex: 'key'},
+ {header: 'Value', flex: 1, dataIndex: 'value'}
+ ]
+ }
+ ],
+ dockedItems: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'start',
+ dock: 'bottom',
+ margin: '5 0 0 0',
+ boxLabel: gettext('Start after created')
+ }
+ ],
+ listeners: {
+ show: function(panel) {
+ var kv = this.up('window').getValues();
+ var data = [];
+ Ext.Object.each(kv, function(key, value) {
+ if (key === 'delete') { // ignore
+ return;
+ }
+ data.push({ key: key, value: value });
+ });
+
+ var summarystore = panel.down('grid').getStore();
+ summarystore.suspendEvents();
+ summarystore.removeAll();
+ summarystore.add(data);
+ summarystore.sort();
+ summarystore.resumeEvents();
+ summarystore.fireEvent('refresh');
+
+ }
+ },
+ onSubmit: function() {
+ var wizard = this.up('window');
+ var kv = wizard.getValues();
+ delete kv['delete'];
+
+ var nodename = kv.nodename;
+ delete kv.nodename;
+
+ Proxmox.Utils.API2Request({
+ url: '/nodes/' + nodename + '/qemu',
+ waitMsgTarget: wizard,
+ method: 'POST',
+ params: kv,
+ success: function(response){
+ wizard.close();
+ },
+ failure: function(response, opts) {
+ Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+ }
+ });
+ }
+ }
+ ],
+
+});
\ No newline at end of file
diff --git a/www/manager6/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js
new file mode 100644
index 00000000..f9ad6dcb
--- /dev/null
+++ b/www/manager6/qemu/MultiHDEdit.js
@@ -0,0 +1,278 @@
+Ext.define('PVE.qemu.MultiHDInputPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuMultiHDInputPanel',
+
+ insideWizard: false,
+
+ hiddenDisks: [],
+
+ leftColumnRatio: 0.25,
+
+ column1: [
+ {
+ // Adding to the HDInputPanelContainer below automatically adds
+ // items to this store
+ xtype: 'gridpanel',
+ scrollable: true,
+ store: {
+ xtype: 'store',
+ storeId: 'importwizard_diskstorage',
+ // Use the panel as id
+ // Panels have are objects and therefore unique
+ // E.g. while adding new panels 'device' is ambiguous
+ fields: ['device', 'panel'],
+ removeByPanel: function(panel) {
+ let recordIndex = this.findBy(record =>
+ record.data.panel === panel,
+ );
+ this.removeAt(recordIndex);
+ return recordIndex;
+ },
+ },
+ columns: [
+ {
+ text: gettext('Target device'),
+ dataIndex: 'device',
+ flex: 1,
+ resizable: false,
+ },
+ ],
+ listeners: {
+ select: function(_, record) {
+ this.up('pveQemuMultiHDInputPanel')
+ .down('#HDInputPanelContainer')
+ .setActiveItem(record.data.panel);
+ },
+ },
+ anchor: '100% 90%', // TODO Resize to parent
+ },{
+ xtype: 'container',
+ layout: 'hbox',
+ center: true, // TODO fix me
+ defaults: {
+ margin: '5',
+ xtype: 'button',
+ },
+ items: [
+ {
+ iconCls: 'fa fa-plus-circle',
+ itemId: 'addDisk',
+ handler: function(button) {
+ button.up('pveQemuMultiHDInputPanel').addDiskFunction();
+ },
+ },{
+ iconCls: 'fa fa-trash-o',
+ itemId: 'removeDisk',
+ handler: function(button) {
+ button.up('pveQemuMultiHDInputPanel').removeCurrentDisk();
+ },
+ }
+ ],
+ }
+ ],
+ column2: [
+ {
+ itemId: 'HDInputPanelContainer',
+ xtype: 'container',
+ layout: 'card',
+ items: [],
+ listeners: {
+ beforeRender: function() {
+ // Initial disk if none have been added by manifest yet
+ if (this.items.items.length === 0) {
+ this.addDiskFunction();
+ }
+ },
+ add: function(container, newPanel, index) {
+ let store = Ext.getStore('importwizard_diskstorage');
+ store.add({ device: newPanel.getDevice(), panel: newPanel });
+ container.setActiveItem(newPanel);
+ },
+ remove: function(HDInputPanelContainer, HDInputPanel, eOpts) {
+ let store = Ext.getStore('importwizard_diskstorage');
+ let previousCount = store.data.getCount();
+ let indexOfRemoved = store.removeByPanel(HDInputPanel);
+ if (HDInputPanelContainer.items.getCount() > 0) {
+ HDInputPanelContainer.setActiveItem(indexOfRemoved - 1);
+ }
+ },
+ },
+ defaultItem: {
+ xtype: 'pveQemuHDInputPanel',
+ bind: {
+ nodename: '{nodename}',
+ },
+ isCreate: true,
+ isImport: true,
+ showSourcePathTextfield: true,
+ returnSingleKey: true,
+ insideWizard: true,
+ setNodename: function(nodename) {
+ this.down('#hdstorage').setNodename(nodename);
+ this.down('#hdimage').setStorage(undefined, nodename);
+ this.down('#sourceStorageSelector').setNodename(nodename);
+ this.down('#sourceFileSelector').setNodename(nodename);
+ },
+ listeners: {
+ // newHDInputPanel ... the defaultItem that has just been
+ // cloned and added into HDInputPnaleContainer parameter
+ // HDInputPanelContainer ... the container from column2
+ // where all the new panels go into
+ added: function(newHDInputPanel, HDInputPanelContainer, pos) {
+ // The listeners cannot be added earlier, because its fields don't exist earlier
+ Ext.Array.each(this.down('pveControllerSelector')
+ .query('field'), function(field) {
+ field.on('change', function() {
+ // Note that one setValues in a controller
+ // selector makes one setValue in each of
+ // the two fields, so this listener fires
+ // two times in a row so to say e.g.
+ // changing controller selector from ide0 to
+ // sata1 makes ide0->sata0 and then
+ // sata0->sata1
+ let store = Ext.getStore('importwizard_diskstorage');
+ let controllerSelector = field.up('pveQemuHDInputPanel')
+ .down('pveControllerSelector');
+ /*
+ * controller+device (ide0) might be
+ * ambiguous during creation => find by
+ * panel object instead
+ *
+ * There is no function that takes a
+ * function and returns the model directly
+ * => index & getAt
+ */
+ let recordIndex = store.findBy(record =>
+ record.data.panel === field.up('pveQemuHDInputPanel'),
+ );
+ let newControllerAndId = controllerSelector.getValuesAsString();
+ store.getAt(recordIndex).set('device', newControllerAndId);
+ });
+ },
+ );
+ let wizard = this.up('pveQemuImportWizard');
+ Ext.Array.each(this.query('field'), function(field) {
+ field.on('change', wizard.validcheck);
+ field.on('validitychange', wizard.validcheck);
+ });
+ },
+ },
+ validator: function() {
+ var valid = true;
+ var fields = this.query('field, fieldcontainer');
+ Ext.Array.each(fields, function(field) {
+ // Note: not all fielcontainer have isValid()
+ if (Ext.isFunction(field.isValid) && !field.isValid()) {
+ valid = false;
+ }
+ });
+ return valid;
+ }
+ },
+
+ // device ... device that the new disk should be assigned to, e.g.
+ // ide0, sata2
+ // path ... if this is set to x then the disk will
+ // backed/imported from the path x, that is, the textfield will
+ // contain the value x
+ addDiskFunction(device, path) {
+ // creating directly removes binding => no storage found?
+ let item = Ext.clone(this.defaultItem);
+ let added = this.add(item);
+ // At this point the 'added' listener has fired and the fields
+ // in the variable added have the change listeners that update
+ // the store Therefore we can now set values only on the field
+ // and they will be updated in the store
+ if (path) {
+ added.down('#sourceRadioPath').setValue(true);
+ added.down('#sourcePathTextfield').setValue(path);
+ } else {
+ added.down('#sourceRadioStorage').setValue(true);
+ added.down('#sourceStorageSelector').setHidden(false);
+ added.down('#sourceFileSelector').setHidden(false);
+ added.down('#sourceFileSelector').enable();
+ added.down('#sourceStorageSelector').enable();
+ }
+ if (device) {
+ // This happens after the 'add' and 'added' listeners of the
+ // item/defaultItem clone/pveQemuHDInputPanel/added have fired
+ added.down('pveControllerSelector').setValue(device);
+ }
+ },
+ removeCurrentDisk: function() {
+ let activePanel = this.getLayout().activeItem; // panel = disk
+ if (activePanel) {
+ this.remove(activePanel);
+ } else {
+ // TODO Add tooltip to Remove disk button
+ }
+ },
+ },
+ ],
+
+ addDiskFunction: function(device, path) {
+ this.down('#HDInputPanelContainer').addDiskFunction(device, path);
+ },
+ removeCurrentDisk: function() {
+ this.down('#HDInputPanelContainer').removeCurrentDisk();
+ },
+ removeAllDisks: function() {
+ let container = this.down('#HDInputPanelContainer');
+ while (container.items.items.length > 0) {
+ container.removeCurrentDisk();
+ }
+ },
+
+ beforeRender: function() {
+ let leftColumnPanel = this.items.get(0).items.get(0);
+ leftColumnPanel.setFlex(this.leftColumnRatio);
+ // any other panel because this has no height yet
+ let panelHeight = this.up('tabpanel').items.items[0].getHeight();
+ leftColumnPanel.setHeight(panelHeight);
+ },
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ },
+
+ // Call with defined parameter or without (static function so to say)
+ hasDuplicateDevices: function(values) {
+ if (!values) {
+ values = this.up('form').getValues();
+ }
+ if (!Array.isArray(values.controller)) {
+ return false;
+ }
+ for (let i = 0; i < values.controller.length - 1; i++) {
+ for (let j = i+1; j < values.controller.length; j++) {
+ if (values.controller[i] === values.controller[j]) {
+ if (values.deviceid[i] === values.deviceid[j]) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ },
+
+ onGetValues: function(values) {
+ // Returning anything here would give wrong data in the form at the end
+ // of the wizrad Each HDInputPanel in this MultiHD panel already has a
+ // sufficient onGetValues() function for the form at the end of the
+ // wizard
+ if (this.hasDuplicateDevices(values)) {
+ Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
+ }
+ },
+
+ validator: function() {
+ let inputpanels = this.down('#HDInputPanelContainer').items.getRange();
+ if (inputpanels.some(panel => !panel.validator())) {
+ return false;
+ }
+ if (this.hasDuplicateDevices()) {
+ return false;
+ }
+ return true;
+ },
+});
diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
index 87e4bf0a..51a1c096 100644
--- a/www/manager6/window/Wizard.js
+++ b/www/manager6/window/Wizard.js
@@ -263,6 +263,8 @@ Ext.define('PVE.window.Wizard', {
};
field.on('change', validcheck);
field.on('validitychange', validcheck);
+ // Make available for fields that get added later
+ me.validcheck = validcheck;
});
}
});
--
2.20.1
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2020-12-18 11:50 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2020-12-18 11:49 [pve-devel] [PATCH qemu-server v3] Prepare API for import GUI Dominic Jäger
2020-12-18 11:49 ` [pve-devel] [PATCH manager v3] gui: Add import for disk & VM Dominic Jäger
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox