all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH] Add API for VM import
@ 2021-04-12 10:08 Dominic Jäger
  2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger
  2021-04-22 20:06 ` [pve-devel] [PATCH] Add API for VM import Thomas Lamprecht
  0 siblings, 2 replies; 4+ messages in thread
From: Dominic Jäger @ 2021-04-12 10:08 UTC (permalink / raw)
  To: pve-devel

Extend qm importdisk/importovf functionality to the API.

Co-authored-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
v8:
- Fabian moved the import functions into the existing create_vm / update_vm_api
- Dropped the separate API endpoints & import lock

 PVE/API2/Qemu.pm       | 175 +++++++++++++++++++++++++++++++++++++++--
 PVE/API2/Qemu/Makefile |   2 +-
 PVE/API2/Qemu/OVF.pm   |  68 ++++++++++++++++
 PVE/QemuServer.pm      |  50 ++++++++++--
 PVE/QemuServer/OVF.pm  |  10 ++-
 5 files changed, 289 insertions(+), 16 deletions(-)
 create mode 100644 PVE/API2/Qemu/OVF.pm

diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm
index c56b609..5c31756 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);
 
@@ -61,7 +60,19 @@ my $resolve_cdrom_alias = sub {
     }
 };
 
+my $parse_import_sources = sub {
+    my $param = shift;
+    my $import = {};
+    foreach my $pair (PVE::Tools::split_list($param)) {
+	my ($device, $diskimage) = split('=', $pair);
+	$import->{$device} = $diskimage;
+    }
+
+    return $import;
+};
+
 my $NEW_DISK_RE = qr!^(([^/:\s]+):)?(\d+(\.\d+)?)$!;
+my $IMPORT_DISK_RE = qr!^([^/:\s]+):-1$!;
 my $check_storage_access = sub {
    my ($rpcenv, $authuser, $storecfg, $vmid, $settings, $default_storage) = @_;
 
@@ -84,6 +95,12 @@ my $check_storage_access = sub {
 	    my $scfg = PVE::Storage::storage_config($storecfg, $storeid);
 	    raise_param_exc({ storage => "storage '$storeid' does not support vm images"})
 		if !$scfg->{content}->{images};
+	} elsif ($volid =~ $IMPORT_DISK_RE) {
+	    my $storeid = $1;
+	    $rpcenv->check($authuser, "/storage/$storeid", ['Datastore.AllocateSpace']);
+	    my $scfg = PVE::Storage::storage_config($storecfg, $storeid);
+	    raise_param_exc({ storage => "storage '$storeid' does not support vm images"})
+		if !$scfg->{content}->{images};
 	} else {
 	    PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg, $vmid, $volid);
 	}
@@ -91,6 +108,13 @@ my $check_storage_access = sub {
 
    $rpcenv->check($authuser, "/storage/$settings->{vmstatestorage}", ['Datastore.AllocateSpace'])
        if defined($settings->{vmstatestorage});
+
+    if (defined($settings->{import_sources})) {
+	my $images = $parse_import_sources->($settings->{import_sources});
+	foreach my $source_image (values %$images) {
+	    PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg, $vmid, $source_image);
+	}
+    }
 };
 
 my $check_storage_access_clone = sub {
@@ -133,10 +157,25 @@ my $check_storage_access_clone = sub {
    return $sharedvm;
 };
 
+# Raise exception if $format is not supported by $storeid
+my $check_format_is_supported = sub {
+    my ($format, $storeid, $storecfg) = @_;
+    die "storage ID parameter must be passed to the sub" if !$storeid;
+    die "storage configuration must be passed to the sub" if !$storecfg;
+
+    return if !$format;
+
+    my (undef, $valid_formats) = PVE::Storage::storage_default_format($storecfg, $storeid);
+    my $supported = grep { $_ eq $format } @$valid_formats;
+
+    die "format '$format' is not supported on storage $storeid" if !$supported;
+};
+
+
 # Note: $pool is only needed when creating a VM, because pool permissions
 # are automatically inherited if VM already exists inside a pool.
 my $create_disks = sub {
-    my ($rpcenv, $authuser, $conf, $arch, $storecfg, $vmid, $pool, $settings, $default_storage) = @_;
+    my ($rpcenv, $authuser, $conf, $arch, $storecfg, $vmid, $pool, $settings, $default_storage, $import) = @_;
 
     my $vollist = [];
 
@@ -192,6 +231,69 @@ my $create_disks = sub {
 	    $disk->{size} = PVE::Tools::convert_size($size, 'kb' => 'b');
 	    delete $disk->{format}; # no longer needed
 	    $res->{$ds} = PVE::QemuServer::print_drive($disk);
+	} elsif ($volid =~ $IMPORT_DISK_RE) {
+	    my $target_storage = $1;
+
+	    my $source = $import->{$ds};
+	    die "cannot import '$ds', no import source defined\n" if !$source;
+	    $source = PVE::Storage::abs_filesystem_path($storecfg, $source, 1);
+	    my $src_size = PVE::Storage::file_size_info($source);
+	    die "Could not get file size of $source" if !defined($src_size);
+
+	    $check_format_is_supported->($disk->{format}, $storeid, $storecfg);
+
+	    my $dst_format = PVE::QemuServer::resolve_dst_disk_format(
+		$storecfg,
+		$storeid,
+		undef,
+		$disk->{format},
+	    );
+	    my $dst_volid = PVE::Storage::vdisk_alloc(
+		$storecfg,
+		$storeid,
+		$vmid,
+		$dst_format,
+		undef,
+		PVE::Tools::convert_size($src_size, 'b' => 'kb'),
+	    );
+
+	    print "Importing disk image '$source' as '$dst_volid'...\n";
+	    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(
+		    $storecfg,
+		    'sparseinit',
+		    $dst_volid,
+		);
+		PVE::Storage::activate_volumes($storecfg, [$dst_volid]);
+		PVE::QemuServer::qemu_img_convert(
+		    $source,
+		    $dst_volid,
+		    $src_size,
+		    undef,
+		    $zeroinit,
+		);
+		PVE::Storage::deactivate_volumes($storecfg, [$dst_volid]);
+
+	    };
+	    if (my $err = $@) {
+		eval { PVE::Storage::vdisk_free($storecfg, $dst_volid) };
+		warn "Cleanup of $dst_volid failed: $@ \n" if $@;
+
+		die "Importing disk '$source' failed: $err\n" if $err;
+	    }
+	    push @$vollist, $dst_volid;
+	    $disk->{file} = $dst_volid;
+	    if ($ds !~ /^unused\d+$/) {
+		$disk->{size} = $src_size;
+		delete $disk->{format}; # no longer needed
+	    }
+	    $res->{$ds} = PVE::QemuServer::print_drive($disk);
 	} else {
 
 	    PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg, $vmid, $volid);
@@ -218,7 +320,7 @@ my $create_disks = sub {
 	}
     };
 
-    eval { PVE::QemuConfig->foreach_volume($settings, $code); };
+    eval { PVE::QemuConfig->foreach_volume_full($settings, { include_unused => 1 }, $code); };
 
     # free allocated images on error
     if (my $err = $@) {
@@ -550,6 +652,13 @@ __PACKAGE__->register_method({
 		    default => 0,
 		    description => "Start VM after it was created successfully.",
 		},
+		import_sources => {
+		    description => "\\0 delimited mapping of devices to disk images to import." .
+			"For example, scsi0=/mnt/nfs/image1.vmdk",
+		    type => 'string',
+		    format => 'device-image-pair-alist',
+		    optional => 1,
+		},
 	    }),
     },
     returns => {
@@ -615,21 +724,34 @@ __PACKAGE__->register_method({
 
 	    &$check_cpu_model_access($rpcenv, $authuser, $param);
 
+	    my $import_devices = $parse_import_sources->($param->{import_sources});
+
 	    foreach my $opt (keys %$param) {
 		if (PVE::QemuServer::is_valid_drivename($opt)) {
 		    my $drive = PVE::QemuServer::parse_drive($opt, $param->{$opt});
 		    raise_param_exc({ $opt => "unable to parse drive options" }) if !$drive;
+		    raise_param_exc({ $opt => "not marked for import, but import source defined" })
+			if $drive->{file} !~ $IMPORT_DISK_RE && $import_devices->{$opt};
+		    raise_param_exc({ $opt => "marked for import, but no import source defined" })
+			if $drive->{file} =~ $IMPORT_DISK_RE && !$import_devices->{$opt};
 
 		    PVE::QemuServer::cleanup_drive_path($opt, $storecfg, $drive);
 		    $param->{$opt} = PVE::QemuServer::print_drive($drive);
 		}
 	    }
+	    foreach my $opt (keys %$import_devices) {
+		raise_param_exc({ import_sources => "$opt not marked for import, but import source defined" })
+		    if !defined($param->{$opt});
+
+	    }
 
 	    PVE::QemuServer::add_random_macs($param);
 	} else {
 	    my $keystr = join(' ', keys %$param);
 	    raise_param_exc({ archive => "option conflicts with other options ($keystr)"}) if $keystr;
 
+	    raise_param_exc({ import_sources => "cannot import existing disk and restore backup." }) if $param->{import_sources};
+
 	    if ($archive eq '-') {
 		die "pipe requires cli environment\n"
 		    if $rpcenv->{type} ne 'cli';
@@ -701,10 +823,22 @@ __PACKAGE__->register_method({
 	    my $realcmd = sub {
 		my $conf = $param;
 		my $arch = PVE::QemuServer::get_vm_arch($conf);
+		my $import = $parse_import_sources->(extract_param($param, "import_sources"));
 
 		my $vollist = [];
 		eval {
-		    $vollist = &$create_disks($rpcenv, $authuser, $conf, $arch, $storecfg, $vmid, $pool, $param, $storage);
+		    $vollist = &$create_disks(
+			$rpcenv,
+			$authuser,
+			$conf,
+			$arch,
+			$storecfg,
+			$vmid,
+			$pool,
+			$param,
+			$storage,
+			$import
+		    );
 
 		    if (!$conf->{boot}) {
 			my $devs = PVE::QemuServer::get_default_bootdevices($conf);
@@ -1181,11 +1315,17 @@ my $update_vm_api  = sub {
 	die "cannot add non-replicatable volume to a replicated VM\n";
     };
 
+    my $import_devices = $parse_import_sources->($param->{import_sources});
+
     foreach my $opt (keys %$param) {
 	if (PVE::QemuServer::is_valid_drivename($opt)) {
 	    # cleanup drive path
 	    my $drive = PVE::QemuServer::parse_drive($opt, $param->{$opt});
 	    raise_param_exc({ $opt => "unable to parse drive options" }) if !$drive;
+	    raise_param_exc({ $opt => "not marked for import, but import source defined" })
+		if $drive->{file} !~ $IMPORT_DISK_RE && $import_devices->{$opt};
+	    raise_param_exc({ $opt => "marked for import, but no import source defined" })
+		if $drive->{file} =~ $IMPORT_DISK_RE && !$import_devices->{$opt};
 	    PVE::QemuServer::cleanup_drive_path($opt, $storecfg, $drive);
 	    $check_replication->($drive);
 	    $param->{$opt} = PVE::QemuServer::print_drive($drive);
@@ -1203,12 +1343,20 @@ my $update_vm_api  = sub {
 	}
     }
 
+    foreach my $opt (keys %$import_devices) {
+	raise_param_exc({ import_sources => "$opt not marked for import, but import source defined" })
+	    if !defined($param->{$opt});
+
+    }
+
     &$check_vm_modify_config_perm($rpcenv, $authuser, $vmid, undef, [@delete]);
 
     &$check_vm_modify_config_perm($rpcenv, $authuser, $vmid, undef, [keys %$param]);
 
     &$check_storage_access($rpcenv, $authuser, $storecfg, $vmid, $param);
 
+    delete $param->{import_sources};
+
     my $updatefn =  sub {
 
 	my $conf = PVE::QemuConfig->load_config($vmid);
@@ -1350,7 +1498,17 @@ my $update_vm_api  = sub {
 		    PVE::QemuServer::vmconfig_register_unused_drive($storecfg, $vmid, $conf, PVE::QemuServer::parse_drive($opt, $conf->{pending}->{$opt}))
 			if defined($conf->{pending}->{$opt});
 
-		    &$create_disks($rpcenv, $authuser, $conf->{pending}, $arch, $storecfg, $vmid, undef, {$opt => $param->{$opt}});
+		    &$create_disks(
+			$rpcenv,
+			$authuser,
+			$conf->{pending},
+			$arch, $storecfg,
+			$vmid,
+			undef,
+			{$opt => $param->{$opt}},
+			undef,
+			{$opt => $import_devices->{$opt}}
+		    );
 		} 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']);
@@ -1494,6 +1652,13 @@ __PACKAGE__->register_method({
 		    optional => 1,
 		    requires => 'delete',
 		},
+		import_sources => {
+		    description => "\\0 delimited mapping of devices to disk images to import." .
+			"For example, scsi0=/mnt/nfs/image1.vmdk",
+		    type => 'string',
+		    format => 'device-image-pair-alist',
+		    optional => 1,
+		},
 		digest => {
 		    type => 'string',
 		    description => 'Prevent changes if current configuration file has different SHA1 digest. This can be used to prevent concurrent modifications.',
diff --git a/PVE/API2/Qemu/Makefile b/PVE/API2/Qemu/Makefile
index 5d4abda..bdd4762 100644
--- a/PVE/API2/Qemu/Makefile
+++ b/PVE/API2/Qemu/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Agent.pm CPU.pm Machine.pm
+SOURCES=Agent.pm CPU.pm Machine.pm OVF.pm
 
 .PHONY: install
 install:
diff --git a/PVE/API2/Qemu/OVF.pm b/PVE/API2/Qemu/OVF.pm
new file mode 100644
index 0000000..bd6e90b
--- /dev/null
+++ b/PVE/API2/Qemu/OVF.pm
@@ -0,0 +1,68 @@
+package PVE::API2::Qemu::OVF;
+
+use strict;
+use warnings;
+
+use PVE::RESTHandler;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::QemuServer::OVF;
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    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",
+    },
+    returns => {
+	type => 'object',
+	additionalProperties => 1,
+	properties => PVE::QemuServer::json_ovf_properties({
+	    name => {
+		type => 'string',
+		optional => 1,
+	    },
+	    cores => {
+		type => 'integer',
+		optional => 1,
+	    },
+	    memory => {
+		type => 'integer',
+		optional => 1,
+	    },
+	}),
+    },
+    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->{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}} = $disk->{backing_file};
+	}
+	return $result;
+    }});
+
+1;
\ No newline at end of file
diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm
index fdb2ac9..ac2fe2e 100644
--- a/PVE/QemuServer.pm
+++ b/PVE/QemuServer.pm
@@ -987,19 +987,41 @@ PVE::JSONSchema::register_format('pve-volume-id-or-qm-path', \&verify_volume_id_
 sub verify_volume_id_or_qm_path {
     my ($volid, $noerr) = @_;
 
-    if ($volid eq 'none' || $volid eq 'cdrom' || $volid =~ m|^/|) {
-	return $volid;
-    }
+    return $volid eq 'none' || $volid eq 'cdrom' ?
+	$volid :
+	verify_volume_id_or_absolute_path($volid, $noerr);
+}
+
+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) = @_;
+
+    return $volid if $volid =~ m|^/|;
 
-    # if its neither 'none' nor 'cdrom' nor a path, check if its a volume-id
     $volid = eval { PVE::JSONSchema::check_format('pve-volume-id', $volid, '') };
     if ($@) {
-	return if $noerr;
+	return undef if $noerr;
 	die $@;
     }
     return $volid;
 }
 
+PVE::JSONSchema::register_format('device-image-pair', \&verify_device_image_pair);
+sub verify_device_image_pair {
+    my ($pair, $noerr) = @_;
+
+    my $error = sub {
+	return undef if $noerr;
+	die @_;
+    };
+
+    my ($device, $image) = split('=', $pair);
+    $error->("Invalid device '$device'") if !PVE::QemuServer::Drive::is_valid_drivename($device);
+    $error->("Invalid image '$image'") if !verify_volume_id_or_absolute_path($image);
+
+    return $pair;
+}
+
 my $usb_fmt = {
     host => {
 	default_key => 1,
@@ -2075,6 +2097,22 @@ sub json_config_properties {
     return $prop;
 }
 
+# Properties that we can read from an OVF file
+sub json_ovf_properties {
+    my $prop = shift;
+
+    foreach my $device ( PVE::QemuServer::Drive::valid_drive_names()) {
+	$prop->{$device} = {
+	    type => 'string',
+	    format => 'pve-volume-id-or-absolute-path',
+	    description => "Disk image that gets imported to $device",
+	    optional => 1,
+	};
+    }
+
+    return $prop;
+}
+
 # return copy of $confdesc_cloudinit to generate documentation
 sub cloudinit_config_properties {
 
@@ -6914,7 +6952,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 $src_volid) {
 	$src_path = $src_volid;
 	if ($src_path =~ m/\.($PVE::QemuServer::Drive::QEMU_FORMAT_RE)$/) {
 	    $src_format = $1;
diff --git a/PVE/QemuServer/OVF.pm b/PVE/QemuServer/OVF.pm
index c76c199..48146e9 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, $manifest_only) = @_;
 
     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 = undef;
+	if (!$manifest_only) { # 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] 4+ messages in thread

* [pve-devel] [PATCH] Add GUI to import disk & VM
  2021-04-12 10:08 [pve-devel] [PATCH] Add API for VM import Dominic Jäger
@ 2021-04-12 10:08 ` Dominic Jäger
  2021-04-13 10:11   ` Oguz Bektas
  2021-04-22 20:06 ` [pve-devel] [PATCH] Add API for VM import Thomas Lamprecht
  1 sibling, 1 reply; 4+ messages in thread
From: Dominic Jäger @ 2021-04-12 10:08 UTC (permalink / raw)
  To: pve-devel

Add GUI wizard to import whole VMs and a window to import single disks in
Hardware View.

Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
v8:
- Adapt to new API
- Some small fixes
- Much renaming

 PVE/API2/Nodes.pm                       |   7 +
 www/manager6/Makefile                   |   2 +
 www/manager6/Workspace.js               |  15 ++
 www/manager6/form/ControllerSelector.js |  15 ++
 www/manager6/node/CmdMenu.js            |  13 +
 www/manager6/qemu/HDEdit.js             | 149 ++++++++++-
 www/manager6/qemu/HDEditCollection.js   | 263 ++++++++++++++++++++
 www/manager6/qemu/HardwareView.js       |  24 ++
 www/manager6/qemu/ImportWizard.js       | 317 ++++++++++++++++++++++++
 www/manager6/window/Wizard.js           |   2 +
 10 files changed, 795 insertions(+), 12 deletions(-)
 create mode 100644 www/manager6/qemu/HDEditCollection.js
 create mode 100644 www/manager6/qemu/ImportWizard.js

diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index ba6621c6..1cee6cb5 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -48,6 +48,7 @@ use PVE::API2::LXC;
 use PVE::API2::Network;
 use PVE::API2::NodeConfig;
 use PVE::API2::Qemu::CPU;
+use PVE::API2::Qemu::OVF;
 use PVE::API2::Qemu;
 use PVE::API2::Replication;
 use PVE::API2::Services;
@@ -76,6 +77,11 @@ __PACKAGE__->register_method ({
     path => 'cpu',
 });
 
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Qemu::OVF",
+    path => 'readovf',
+});
+
 __PACKAGE__->register_method ({
     subclass => "PVE::API2::LXC",
     path => 'lxc',
@@ -2183,6 +2189,7 @@ __PACKAGE__->register_method ({
 	return undef;
     }});
 
+
 # bash completion helper
 
 sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index a2f7be6d..dbb85062 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/HDEditCollection.js				\
 	qemu/HDEfi.js					\
 	qemu/HDMove.js					\
 	qemu/HDResize.js				\
diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 0c1b9e0c..631739a0 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -280,11 +280,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"),
+	    hidden: Proxmox.UserName !== 'root@pam',
+	    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']);
 	    }
 	});
 
@@ -332,6 +346,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 23c61159..f515b220 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) {
+	const regex = /([a-z]+)(\d+)/;
+	const [_, controller, deviceid] = regex.exec(value);
+	this.down('field[name=controller]').setValue(controller);
+	this.down('field[name=deviceid]').setValue(deviceid);
+    },
+
     initComponent: function() {
 	var me = this;
 
diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js
index b650bfa0..b66c7a6e 100644
--- a/www/manager6/node/CmdMenu.js
+++ b/www/manager6/node/CmdMenu.js
@@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', {
 		wiz.show();
 	    },
 	},
+	{
+	    text: gettext("Import VM"),
+	    hidden: Proxmox.UserName !== 'root@pam',
+	    itemId: 'importvm',
+	    iconCls: 'fa fa-cube',
+	    handler: function() {
+		const me = this.up('menu');
+		const 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 e22111bf..3af7e624 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -58,6 +58,17 @@ 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 HDInputPanelCollection to get the selection for each HDInputPanel => Make
+    names so that those within one HDInputPanel are equal, but different from other
+    HDInputPanels
+    */
+    getSourceTypeID() {
+	return 'sourceType_' + this.id;
+    },
+
     onGetValues: function(values) {
 	var me = this;
 
@@ -70,6 +81,8 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	} else if (me.isCreate) {
 	    if (values.hdimage) {
 		me.drive.file = values.hdimage;
+	    } else if (me.isImport) {
+		me.drive.file = `${values.hdstorage}:-1`;
 	    } else {
 		me.drive.file = values.hdstorage + ":" + values.disksize;
 	    }
@@ -83,13 +96,21 @@ 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);
-        });
+	});
 
+	const getSourceImageLocation = function() {
+	    const type = values[me.getSourceTypeID()];
+	    return type === 'storage' ? values.sourceVolid : values.sourcePath;
+	};
+
+	if (me.isImport) {
+	    params.import_sources = `${confid}=${getSourceImageLocation()}`;
+	}
 
 	params[confid] = PVE.Parser.printQemuDrive(me.drive);
 
@@ -149,6 +170,10 @@ 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);
@@ -169,10 +194,15 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	me.advancedColumn2 = [];
 
 	if (!me.confid || me.unused) {
+	    let controllerColumn = me.isImport ? 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.isImport) {
+		me.bussel.fieldLabel = 'Target Device';
+	    }
+	    controllerColumn.push(me.bussel);
 
 	    me.scsiController = Ext.create('Ext.form.field.Display', {
 		fieldLabel: gettext('SCSI Controller'),
@@ -184,7 +214,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		submitValue: false,
 		hidden: true,
 	    });
-	    me.column1.push(me.scsiController);
+	    controllerColumn.push(me.scsiController);
 	}
 
 	if (me.unused) {
@@ -199,14 +229,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.isImport) {
+	    let selector = {
 		xtype: 'pveDiskStorageSelector',
 		storageContent: 'images',
 		name: 'disk',
 		nodename: me.nodename,
-		autoSelect: me.insideWizard,
-	    });
+		hideSize: me.isImport,
+		autoSelect: me.insideWizard || me.isImport,
+	    };
+	    if (me.isImport) {
+		selector.storageLabel = gettext('Target storage');
+		me.column2.push(selector);
+	    } else {
+		me.column1.push(selector);
+	    }
 	} else {
 	    me.column1.push({
 		xtype: 'textfield',
@@ -217,6 +254,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	    });
 	}
 
+	if (me.isImport) {
+	    me.column2.push({
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    });
+	}
 	me.column2.push(
 	    {
 		xtype: 'CacheTypeSelector',
@@ -231,6 +274,84 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		name: 'discard',
 	    },
 	);
+	if (me.isImport) {
+	    me.column1.unshift(
+		{
+		    xtype: 'radiofield',
+		    itemId: 'sourceRadioStorage',
+		    name: me.getSourceTypeID(),
+		    inputValue: 'storage',
+		    boxLabel: gettext('Use a storage as source'),
+		    hidden: Proxmox.UserName !== 'root@pam',
+		    checked: true,
+		    listeners: {
+			change: (_, newValue) => {
+			    me.down('#sourceStorageSelector').setHidden(!newValue);
+			    me.down('#sourceStorageSelector').setDisabled(!newValue);
+			    me.down('#sourceFileSelector').setHidden(!newValue);
+			    me.down('#sourceFileSelector').setDisabled(!newValue);
+			},
+		    },
+		}, {
+		    xtype: 'pveStorageSelector',
+		    itemId: 'sourceStorageSelector',
+		    name: 'inputImageStorage',
+		    nodename: me.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: me.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.getSourceTypeID(),
+		    inputValue: 'path',
+		    boxLabel: gettext('Use an absolute path as source'),
+		    hidden: Proxmox.UserName !== 'root@pam',
+		    listeners: {
+			change: (_, newValue) => {
+			    me.down('#sourcePathTextfield').setHidden(!newValue);
+			    me.down('#sourcePathTextfield').setDisabled(!newValue);
+			},
+		    },
+		}, {
+		    xtype: 'textfield',
+		    itemId: 'sourcePathTextfield',
+		    fieldLabel: gettext('Source Path'),
+		    name: 'sourcePath',
+		    autoEl: {
+			tag: 'div',
+			'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'),
+		    },
+		    hidden: true,
+		    disabled: true,
+		    validator: function(insertedText) {
+			return insertedText.startsWith('/') ||
+			    insertedText.startsWith('http') ||
+			    gettext('Must be an absolute path or URL');
+		    },
+		},
+	    );
+	}
 
 	me.advancedColumn1.push(
 	    {
@@ -373,13 +494,17 @@ Ext.define('PVE.qemu.HDEdit', {
 	    nodename: nodename,
 	    unused: unused,
 	    isCreate: me.isCreate,
+	    isImport: 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 + ')';
 	}
diff --git a/www/manager6/qemu/HDEditCollection.js b/www/manager6/qemu/HDEditCollection.js
new file mode 100644
index 00000000..33f6193a
--- /dev/null
+++ b/www/manager6/qemu/HDEditCollection.js
@@ -0,0 +1,263 @@
+Ext.define('PVE.qemu.HDInputPanelCollection', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuHDInputPanelCollection',
+
+    insideWizard: false,
+
+    hiddenDisks: [],
+
+    leftColumnRatio: 0.25,
+
+    column1: [
+	{
+	    // Adding to the panelContainer 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) {
+		    const recordIndex = this.findBy(record => record.data.panel === panel);
+		    this.removeAt(recordIndex);
+		    return recordIndex;
+		},
+		getLast: function() {
+		    const last = this.getCount() - 1;
+		    return this.getAt(last);
+		},
+	    },
+	    columns: [
+		{
+		    text: gettext('Target device'),
+		    dataIndex: 'device',
+		    flex: 1,
+		    resizable: false,
+		},
+	    ],
+	    listeners: {
+		select: function(_, record) {
+		    this.up('pveQemuHDInputPanelCollection')
+			.down('#panelContainer')
+			.setActiveItem(record.data.panel);
+		},
+	    },
+	    anchor: '100% 90%',
+	    selectLast: function() {
+		this.setSelection(this.store.getLast());
+	    },
+	}, {
+	    xtype: 'container',
+	    layout: 'hbox',
+	    center: true,
+	    defaults: {
+		margin: '5',
+		xtype: 'button',
+	    },
+	    items: [
+		{
+		    iconCls: 'fa fa-plus-circle',
+		    itemId: 'addDisk',
+		    handler: function(button) {
+			button.up('pveQemuHDInputPanelCollection').addDisk();
+		    },
+		}, {
+		    iconCls: 'fa fa-trash-o',
+		    itemId: 'removeDisk',
+		    handler: function(button) {
+			button.up('pveQemuHDInputPanelCollection').removeCurrentDisk();
+		    },
+		},
+	    ],
+	},
+    ],
+    column2: [
+	{
+	    itemId: 'panelContainer',
+	    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.addDisk();
+		    }
+		},
+		add: function(container, newPanel) {
+		    const store = Ext.getStore('importwizard_diskstorage');
+		    store.add({ device: newPanel.getDevice(), panel: newPanel });
+		    container.setActiveItem(newPanel);
+		},
+		remove: function(panelContainer, HDInputPanel, eOpts) {
+		    const store = Ext.getStore('importwizard_diskstorage');
+		    store.removeByPanel(HDInputPanel);
+		    if (panelContainer.items.getCount() > 0) {
+			panelContainer.setActiveItem(0);
+		    }
+		},
+	    },
+	    defaultItem: {
+		xtype: 'pveQemuHDInputPanel',
+		bind: {
+		    nodename: '{nodename}',
+		},
+		isCreate: true,
+		isImport: 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: {
+		    // newPanel ... this cloned + added defaultItem
+		    added: function(newPanel) {
+			Ext.Array.each(newPanel.down('pveControllerSelector').query('field'),
+			    function(field) {
+				// Add here because the fields don't exist earlier
+				field.on('change', function() {
+				    const store = Ext.getStore('importwizard_diskstorage');
+
+				    // find by panel object because it is unique
+				    const recordIndex = store.findBy(record =>
+					record.data.panel === field.up('pveQemuHDInputPanel'),
+				    );
+				    const controllerSelector = field.up('pveControllerSelector');
+				    const newControllerAndId = controllerSelector.getValuesAsString();
+
+				    store.getAt(recordIndex).set('device', newControllerAndId);
+				});
+			    },
+			);
+			const 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 ... content of the sourcePathTextfield
+	    addDisk(device, path) {
+		const item = Ext.clone(this.defaultItem);
+		const added = this.add(item);
+		// values in the storage will be updated by listeners
+		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();
+		}
+
+		const sp = Ext.state.Manager.getProvider();
+		const advanced_checkbox = sp.get('proxmox-advanced-cb');
+		added.setAdvancedVisible(advanced_checkbox);
+
+		if (device) {
+		    added.down('pveControllerSelector').setValue(device);
+		}
+		return added;
+	    },
+	    removeCurrentDisk: function() {
+		const activePanel = this.getLayout().activeItem; // panel = disk
+		if (activePanel) {
+		    this.remove(activePanel);
+		}
+	    },
+	},
+    ],
+
+    addDisk: function(device, path) {
+	this.down('#panelContainer').addDisk(device, path);
+	this.down('gridpanel').selectLast();
+    },
+    removeCurrentDisk: function() {
+	this.down('#panelContainer').removeCurrentDisk();
+    },
+    removeAllDisks: function() {
+	const container = this.down('#panelContainer');
+	while (container.items.items.length > 0) {
+	    container.removeCurrentDisk();
+	}
+    },
+
+    beforeRender: function() {
+	const me = this;
+	const leftColumnPanel = me.items.get(0).items.get(0);
+	leftColumnPanel.setFlex(me.leftColumnRatio);
+	// any other panel because this has no height yet
+	const panelHeight = me.up('tabpanel').items.get(0).getHeight();
+	leftColumnPanel.setHeight(panelHeight);
+    },
+
+    setNodename: function(nodename) {
+	this.nodename = nodename;
+    },
+
+    listeners: {
+	afterrender: function() {
+	    const store = Ext.getStore('importwizard_diskstorage');
+	    const first = store.getAt(0);
+	    if (first) {
+		this.down('gridpanel').setSelection(first);
+	    }
+	},
+    },
+
+    // values ... is optional
+    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] &&
+		    values.deviceid[i] === values.deviceid[j]
+		) {
+		    return true;
+		}
+	    }
+	}
+	return false;
+    },
+
+    onGetValues: function(values) {
+	if (this.hasDuplicateDevices(values)) {
+	    Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
+	}
+	// Each child HDInputPanel has sufficient onGetValues() => Return nothing
+    },
+
+    validator: function() {
+	const me = this;
+	const panels = me.down('#panelContainer').items.getRange();
+	return panels.every(panel => panel.validator()) && !me.hasDuplicateDevices();
+    },
+});
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 98352e3f..be4e2d28 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -431,6 +431,29 @@ Ext.define('PVE.qemu.HardwareView', {
 	    handler: run_move,
 	});
 
+	var import_btn = new Proxmox.button.Button({
+	    text: gettext('Import disk'),
+	    hidden: Proxmox.UserName !== 'root@pam',
+	    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('#sourceStorageSelector').show();
+			    component.down('#sourceStorageSelector').enable();
+			    component.down('#sourceFileSelector').enable();
+			    component.down('#sourceFileSelector').show();
+			},
+		    },
+		});
+		win.on('destroy', me.reload, me);
+		win.show();
+	    },
+	});
+
 	var remove_btn = new Proxmox.button.Button({
 	    text: gettext('Remove'),
 	    defaultText: gettext('Remove'),
@@ -759,6 +782,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..a9a63fe3
--- /dev/null
+++ b/www/manager6/qemu/ImportWizard.js
@@ -0,0 +1,317 @@
+/*jslint confusion: true*/
+Ext.define('PVE.qemu.ImportWizard', {
+    extend: 'PVE.window.Wizard',
+    alias: 'widget.pveQemuImportWizard',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    current: {
+		scsihw: '',
+	    },
+	},
+    },
+
+    cbindData: {
+	nodename: undefined,
+    },
+
+    subject: gettext('Import Virtual Machine'),
+
+    isImport: true,
+
+    addDisk: function() {
+	const me = this;
+	const wizard = me.xtype === 'pveQemuImportWizard' ? me : me.up('window');
+	wizard.down('pveQemuHDInputPanelCollection').addDisk();
+    },
+
+    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: [
+		{
+		    xtype: 'label',
+		    itemId: 'successTextfield',
+		    hidden: true,
+		    html: gettext('Manifest successfully uploaded'),
+		    margin: '0 0 0 10',
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'ovfTextfield',
+		    emptyText: '/mnt/nfs/exported.ovf',
+		    fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
+		    listeners: {
+			validitychange: function(_, isValid) {
+			    const button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop();
+			    button.setDisabled(!isValid);
+			},
+		    },
+		    validator: function(value) {
+			return (value && value.startsWith('/')) || gettext("Must start with /");
+		    },
+		},
+		{
+		    xtype: 'proxmoxButton',
+		    itemId: 'load_remote_manifest_button',
+		    text: gettext('Load remote manifest'),
+		    disabled: true,
+		    handler: function() {
+			const inputpanel = this.up('#importInputpanel');
+			const nodename = inputpanel.down('pveNodeSelector').getValue();
+			const ovfTextfieldValue = inputpanel.down('textfield[name=ovfTextfield]').getValue();
+			const wizard = this.up('window');
+			Proxmox.Utils.API2Request({
+			    url: '/nodes/' + nodename + '/readovf',
+			    method: 'GET',
+			    params: {
+				manifest: ovfTextfieldValue,
+			    },
+			    success: function(response) {
+				const 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;
+				const devices = Object.keys(ovfdata); // e.g. ide0, sata2
+				const hdcollection = wizard.down('pveQemuHDInputPanelCollection');
+				hdcollection.removeAllDisks(); // does nothing if already empty
+				devices.forEach(device => hdcollection.addDisk(device, ovfdata[device]));
+			    },
+			    failure: function(response) {
+				Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			    },
+			});
+		    },
+		},
+	    ],
+	    onGetValues: function(values) {
+		delete values.ovfTextfield;
+		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: 'pveQemuHDInputPanelCollection',
+	    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 params = wizard.getValues();
+
+		var nodename = params.nodename;
+		delete params.nodename;
+		delete params.delete;
+		if (Array.isArray(params.import_sources)) {
+		    params.import_sources = params.import_sources.join('\0');
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${nodename}/qemu`,
+		    waitMsgTarget: wizard,
+		    method: 'POST',
+		    params: params,
+		    success: function() {
+			wizard.close();
+		    },
+		    failure: function(response) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    },
+		});
+	    },
+	},
+    ],
+});
diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
index 8b930bbd..a3e3b690 100644
--- a/www/manager6/window/Wizard.js
+++ b/www/manager6/window/Wizard.js
@@ -261,6 +261,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] 4+ messages in thread

* Re: [pve-devel] [PATCH] Add GUI to import disk & VM
  2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger
@ 2021-04-13 10:11   ` Oguz Bektas
  0 siblings, 0 replies; 4+ messages in thread
From: Oguz Bektas @ 2021-04-13 10:11 UTC (permalink / raw)
  To: Proxmox VE development discussion

hi,


tested along with the qemu-server patch, it seems to work but had some
issues in some cases (especially with windows VMs)

we discussed already off-list with dominic during the testing, but i'll
just put these down here anyway:

- the "Next" button should be grayed out until remote manifest is
loaded, or alternatively attempt to load manifest when next button is
pressed (instead of having a separate button)

- in the disk menu, pressing the "+" adds a disk but doesn't increment
the id, which can lead to having multiple disks with the same name

- windows VMs need some special treatment when setting config options:
-> machine: q35
-> use sata disk instead of ide
-> also UEFI can be made default? in my tests the windows 10
installation didn't boot until adding a EFI disk and changing the BIOS
to OVMF


but besides these, it looks okay to me, and works as advertised :)





On Mon, Apr 12, 2021 at 12:08:25PM +0200, Dominic Jäger wrote:
> Add GUI wizard to import whole VMs and a window to import single disks in
> Hardware View.
> 
> Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
> ---
> v8:
> - Adapt to new API
> - Some small fixes
> - Much renaming
> 
>  PVE/API2/Nodes.pm                       |   7 +
>  www/manager6/Makefile                   |   2 +
>  www/manager6/Workspace.js               |  15 ++
>  www/manager6/form/ControllerSelector.js |  15 ++
>  www/manager6/node/CmdMenu.js            |  13 +
>  www/manager6/qemu/HDEdit.js             | 149 ++++++++++-
>  www/manager6/qemu/HDEditCollection.js   | 263 ++++++++++++++++++++
>  www/manager6/qemu/HardwareView.js       |  24 ++
>  www/manager6/qemu/ImportWizard.js       | 317 ++++++++++++++++++++++++
>  www/manager6/window/Wizard.js           |   2 +
>  10 files changed, 795 insertions(+), 12 deletions(-)
>  create mode 100644 www/manager6/qemu/HDEditCollection.js
>  create mode 100644 www/manager6/qemu/ImportWizard.js
> 
> diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
> index ba6621c6..1cee6cb5 100644
> --- a/PVE/API2/Nodes.pm
> +++ b/PVE/API2/Nodes.pm
> @@ -48,6 +48,7 @@ use PVE::API2::LXC;
>  use PVE::API2::Network;
>  use PVE::API2::NodeConfig;
>  use PVE::API2::Qemu::CPU;
> +use PVE::API2::Qemu::OVF;
>  use PVE::API2::Qemu;
>  use PVE::API2::Replication;
>  use PVE::API2::Services;
> @@ -76,6 +77,11 @@ __PACKAGE__->register_method ({
>      path => 'cpu',
>  });
>  
> +__PACKAGE__->register_method ({
> +    subclass => "PVE::API2::Qemu::OVF",
> +    path => 'readovf',
> +});
> +
>  __PACKAGE__->register_method ({
>      subclass => "PVE::API2::LXC",
>      path => 'lxc',
> @@ -2183,6 +2189,7 @@ __PACKAGE__->register_method ({
>  	return undef;
>      }});
>  
> +
>  # bash completion helper
>  
>  sub complete_templet_repo {
> diff --git a/www/manager6/Makefile b/www/manager6/Makefile
> index a2f7be6d..dbb85062 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/HDEditCollection.js				\
>  	qemu/HDEfi.js					\
>  	qemu/HDMove.js					\
>  	qemu/HDResize.js				\
> diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
> index 0c1b9e0c..631739a0 100644
> --- a/www/manager6/Workspace.js
> +++ b/www/manager6/Workspace.js
> @@ -280,11 +280,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"),
> +	    hidden: Proxmox.UserName !== 'root@pam',
> +	    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']);
>  	    }
>  	});
>  
> @@ -332,6 +346,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 23c61159..f515b220 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) {
> +	const regex = /([a-z]+)(\d+)/;
> +	const [_, controller, deviceid] = regex.exec(value);
> +	this.down('field[name=controller]').setValue(controller);
> +	this.down('field[name=deviceid]').setValue(deviceid);
> +    },
> +
>      initComponent: function() {
>  	var me = this;
>  
> diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js
> index b650bfa0..b66c7a6e 100644
> --- a/www/manager6/node/CmdMenu.js
> +++ b/www/manager6/node/CmdMenu.js
> @@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', {
>  		wiz.show();
>  	    },
>  	},
> +	{
> +	    text: gettext("Import VM"),
> +	    hidden: Proxmox.UserName !== 'root@pam',
> +	    itemId: 'importvm',
> +	    iconCls: 'fa fa-cube',
> +	    handler: function() {
> +		const me = this.up('menu');
> +		const 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 e22111bf..3af7e624 100644
> --- a/www/manager6/qemu/HDEdit.js
> +++ b/www/manager6/qemu/HDEdit.js
> @@ -58,6 +58,17 @@ 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 HDInputPanelCollection to get the selection for each HDInputPanel => Make
> +    names so that those within one HDInputPanel are equal, but different from other
> +    HDInputPanels
> +    */
> +    getSourceTypeID() {
> +	return 'sourceType_' + this.id;
> +    },
> +
>      onGetValues: function(values) {
>  	var me = this;
>  
> @@ -70,6 +81,8 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	} else if (me.isCreate) {
>  	    if (values.hdimage) {
>  		me.drive.file = values.hdimage;
> +	    } else if (me.isImport) {
> +		me.drive.file = `${values.hdstorage}:-1`;
>  	    } else {
>  		me.drive.file = values.hdstorage + ":" + values.disksize;
>  	    }
> @@ -83,13 +96,21 @@ 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);
> -        });
> +	});
>  
> +	const getSourceImageLocation = function() {
> +	    const type = values[me.getSourceTypeID()];
> +	    return type === 'storage' ? values.sourceVolid : values.sourcePath;
> +	};
> +
> +	if (me.isImport) {
> +	    params.import_sources = `${confid}=${getSourceImageLocation()}`;
> +	}
>  
>  	params[confid] = PVE.Parser.printQemuDrive(me.drive);
>  
> @@ -149,6 +170,10 @@ 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);
> @@ -169,10 +194,15 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	me.advancedColumn2 = [];
>  
>  	if (!me.confid || me.unused) {
> +	    let controllerColumn = me.isImport ? 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.isImport) {
> +		me.bussel.fieldLabel = 'Target Device';
> +	    }
> +	    controllerColumn.push(me.bussel);
>  
>  	    me.scsiController = Ext.create('Ext.form.field.Display', {
>  		fieldLabel: gettext('SCSI Controller'),
> @@ -184,7 +214,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  		submitValue: false,
>  		hidden: true,
>  	    });
> -	    me.column1.push(me.scsiController);
> +	    controllerColumn.push(me.scsiController);
>  	}
>  
>  	if (me.unused) {
> @@ -199,14 +229,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.isImport) {
> +	    let selector = {
>  		xtype: 'pveDiskStorageSelector',
>  		storageContent: 'images',
>  		name: 'disk',
>  		nodename: me.nodename,
> -		autoSelect: me.insideWizard,
> -	    });
> +		hideSize: me.isImport,
> +		autoSelect: me.insideWizard || me.isImport,
> +	    };
> +	    if (me.isImport) {
> +		selector.storageLabel = gettext('Target storage');
> +		me.column2.push(selector);
> +	    } else {
> +		me.column1.push(selector);
> +	    }
>  	} else {
>  	    me.column1.push({
>  		xtype: 'textfield',
> @@ -217,6 +254,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	    });
>  	}
>  
> +	if (me.isImport) {
> +	    me.column2.push({
> +		xtype: 'box',
> +		autoEl: { tag: 'hr' },
> +	    });
> +	}
>  	me.column2.push(
>  	    {
>  		xtype: 'CacheTypeSelector',
> @@ -231,6 +274,84 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  		name: 'discard',
>  	    },
>  	);
> +	if (me.isImport) {
> +	    me.column1.unshift(
> +		{
> +		    xtype: 'radiofield',
> +		    itemId: 'sourceRadioStorage',
> +		    name: me.getSourceTypeID(),
> +		    inputValue: 'storage',
> +		    boxLabel: gettext('Use a storage as source'),
> +		    hidden: Proxmox.UserName !== 'root@pam',
> +		    checked: true,
> +		    listeners: {
> +			change: (_, newValue) => {
> +			    me.down('#sourceStorageSelector').setHidden(!newValue);
> +			    me.down('#sourceStorageSelector').setDisabled(!newValue);
> +			    me.down('#sourceFileSelector').setHidden(!newValue);
> +			    me.down('#sourceFileSelector').setDisabled(!newValue);
> +			},
> +		    },
> +		}, {
> +		    xtype: 'pveStorageSelector',
> +		    itemId: 'sourceStorageSelector',
> +		    name: 'inputImageStorage',
> +		    nodename: me.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: me.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.getSourceTypeID(),
> +		    inputValue: 'path',
> +		    boxLabel: gettext('Use an absolute path as source'),
> +		    hidden: Proxmox.UserName !== 'root@pam',
> +		    listeners: {
> +			change: (_, newValue) => {
> +			    me.down('#sourcePathTextfield').setHidden(!newValue);
> +			    me.down('#sourcePathTextfield').setDisabled(!newValue);
> +			},
> +		    },
> +		}, {
> +		    xtype: 'textfield',
> +		    itemId: 'sourcePathTextfield',
> +		    fieldLabel: gettext('Source Path'),
> +		    name: 'sourcePath',
> +		    autoEl: {
> +			tag: 'div',
> +			'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'),
> +		    },
> +		    hidden: true,
> +		    disabled: true,
> +		    validator: function(insertedText) {
> +			return insertedText.startsWith('/') ||
> +			    insertedText.startsWith('http') ||
> +			    gettext('Must be an absolute path or URL');
> +		    },
> +		},
> +	    );
> +	}
>  
>  	me.advancedColumn1.push(
>  	    {
> @@ -373,13 +494,17 @@ Ext.define('PVE.qemu.HDEdit', {
>  	    nodename: nodename,
>  	    unused: unused,
>  	    isCreate: me.isCreate,
> +	    isImport: 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 + ')';
>  	}
> diff --git a/www/manager6/qemu/HDEditCollection.js b/www/manager6/qemu/HDEditCollection.js
> new file mode 100644
> index 00000000..33f6193a
> --- /dev/null
> +++ b/www/manager6/qemu/HDEditCollection.js
> @@ -0,0 +1,263 @@
> +Ext.define('PVE.qemu.HDInputPanelCollection', {
> +    extend: 'Proxmox.panel.InputPanel',
> +    alias: 'widget.pveQemuHDInputPanelCollection',
> +
> +    insideWizard: false,
> +
> +    hiddenDisks: [],
> +
> +    leftColumnRatio: 0.25,
> +
> +    column1: [
> +	{
> +	    // Adding to the panelContainer 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) {
> +		    const recordIndex = this.findBy(record => record.data.panel === panel);
> +		    this.removeAt(recordIndex);
> +		    return recordIndex;
> +		},
> +		getLast: function() {
> +		    const last = this.getCount() - 1;
> +		    return this.getAt(last);
> +		},
> +	    },
> +	    columns: [
> +		{
> +		    text: gettext('Target device'),
> +		    dataIndex: 'device',
> +		    flex: 1,
> +		    resizable: false,
> +		},
> +	    ],
> +	    listeners: {
> +		select: function(_, record) {
> +		    this.up('pveQemuHDInputPanelCollection')
> +			.down('#panelContainer')
> +			.setActiveItem(record.data.panel);
> +		},
> +	    },
> +	    anchor: '100% 90%',
> +	    selectLast: function() {
> +		this.setSelection(this.store.getLast());
> +	    },
> +	}, {
> +	    xtype: 'container',
> +	    layout: 'hbox',
> +	    center: true,
> +	    defaults: {
> +		margin: '5',
> +		xtype: 'button',
> +	    },
> +	    items: [
> +		{
> +		    iconCls: 'fa fa-plus-circle',
> +		    itemId: 'addDisk',
> +		    handler: function(button) {
> +			button.up('pveQemuHDInputPanelCollection').addDisk();
> +		    },
> +		}, {
> +		    iconCls: 'fa fa-trash-o',
> +		    itemId: 'removeDisk',
> +		    handler: function(button) {
> +			button.up('pveQemuHDInputPanelCollection').removeCurrentDisk();
> +		    },
> +		},
> +	    ],
> +	},
> +    ],
> +    column2: [
> +	{
> +	    itemId: 'panelContainer',
> +	    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.addDisk();
> +		    }
> +		},
> +		add: function(container, newPanel) {
> +		    const store = Ext.getStore('importwizard_diskstorage');
> +		    store.add({ device: newPanel.getDevice(), panel: newPanel });
> +		    container.setActiveItem(newPanel);
> +		},
> +		remove: function(panelContainer, HDInputPanel, eOpts) {
> +		    const store = Ext.getStore('importwizard_diskstorage');
> +		    store.removeByPanel(HDInputPanel);
> +		    if (panelContainer.items.getCount() > 0) {
> +			panelContainer.setActiveItem(0);
> +		    }
> +		},
> +	    },
> +	    defaultItem: {
> +		xtype: 'pveQemuHDInputPanel',
> +		bind: {
> +		    nodename: '{nodename}',
> +		},
> +		isCreate: true,
> +		isImport: 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: {
> +		    // newPanel ... this cloned + added defaultItem
> +		    added: function(newPanel) {
> +			Ext.Array.each(newPanel.down('pveControllerSelector').query('field'),
> +			    function(field) {
> +				// Add here because the fields don't exist earlier
> +				field.on('change', function() {
> +				    const store = Ext.getStore('importwizard_diskstorage');
> +
> +				    // find by panel object because it is unique
> +				    const recordIndex = store.findBy(record =>
> +					record.data.panel === field.up('pveQemuHDInputPanel'),
> +				    );
> +				    const controllerSelector = field.up('pveControllerSelector');
> +				    const newControllerAndId = controllerSelector.getValuesAsString();
> +
> +				    store.getAt(recordIndex).set('device', newControllerAndId);
> +				});
> +			    },
> +			);
> +			const 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 ... content of the sourcePathTextfield
> +	    addDisk(device, path) {
> +		const item = Ext.clone(this.defaultItem);
> +		const added = this.add(item);
> +		// values in the storage will be updated by listeners
> +		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();
> +		}
> +
> +		const sp = Ext.state.Manager.getProvider();
> +		const advanced_checkbox = sp.get('proxmox-advanced-cb');
> +		added.setAdvancedVisible(advanced_checkbox);
> +
> +		if (device) {
> +		    added.down('pveControllerSelector').setValue(device);
> +		}
> +		return added;
> +	    },
> +	    removeCurrentDisk: function() {
> +		const activePanel = this.getLayout().activeItem; // panel = disk
> +		if (activePanel) {
> +		    this.remove(activePanel);
> +		}
> +	    },
> +	},
> +    ],
> +
> +    addDisk: function(device, path) {
> +	this.down('#panelContainer').addDisk(device, path);
> +	this.down('gridpanel').selectLast();
> +    },
> +    removeCurrentDisk: function() {
> +	this.down('#panelContainer').removeCurrentDisk();
> +    },
> +    removeAllDisks: function() {
> +	const container = this.down('#panelContainer');
> +	while (container.items.items.length > 0) {
> +	    container.removeCurrentDisk();
> +	}
> +    },
> +
> +    beforeRender: function() {
> +	const me = this;
> +	const leftColumnPanel = me.items.get(0).items.get(0);
> +	leftColumnPanel.setFlex(me.leftColumnRatio);
> +	// any other panel because this has no height yet
> +	const panelHeight = me.up('tabpanel').items.get(0).getHeight();
> +	leftColumnPanel.setHeight(panelHeight);
> +    },
> +
> +    setNodename: function(nodename) {
> +	this.nodename = nodename;
> +    },
> +
> +    listeners: {
> +	afterrender: function() {
> +	    const store = Ext.getStore('importwizard_diskstorage');
> +	    const first = store.getAt(0);
> +	    if (first) {
> +		this.down('gridpanel').setSelection(first);
> +	    }
> +	},
> +    },
> +
> +    // values ... is optional
> +    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] &&
> +		    values.deviceid[i] === values.deviceid[j]
> +		) {
> +		    return true;
> +		}
> +	    }
> +	}
> +	return false;
> +    },
> +
> +    onGetValues: function(values) {
> +	if (this.hasDuplicateDevices(values)) {
> +	    Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
> +	}
> +	// Each child HDInputPanel has sufficient onGetValues() => Return nothing
> +    },
> +
> +    validator: function() {
> +	const me = this;
> +	const panels = me.down('#panelContainer').items.getRange();
> +	return panels.every(panel => panel.validator()) && !me.hasDuplicateDevices();
> +    },
> +});
> diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
> index 98352e3f..be4e2d28 100644
> --- a/www/manager6/qemu/HardwareView.js
> +++ b/www/manager6/qemu/HardwareView.js
> @@ -431,6 +431,29 @@ Ext.define('PVE.qemu.HardwareView', {
>  	    handler: run_move,
>  	});
>  
> +	var import_btn = new Proxmox.button.Button({
> +	    text: gettext('Import disk'),
> +	    hidden: Proxmox.UserName !== 'root@pam',
> +	    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('#sourceStorageSelector').show();
> +			    component.down('#sourceStorageSelector').enable();
> +			    component.down('#sourceFileSelector').enable();
> +			    component.down('#sourceFileSelector').show();
> +			},
> +		    },
> +		});
> +		win.on('destroy', me.reload, me);
> +		win.show();
> +	    },
> +	});
> +
>  	var remove_btn = new Proxmox.button.Button({
>  	    text: gettext('Remove'),
>  	    defaultText: gettext('Remove'),
> @@ -759,6 +782,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..a9a63fe3
> --- /dev/null
> +++ b/www/manager6/qemu/ImportWizard.js
> @@ -0,0 +1,317 @@
> +/*jslint confusion: true*/
> +Ext.define('PVE.qemu.ImportWizard', {
> +    extend: 'PVE.window.Wizard',
> +    alias: 'widget.pveQemuImportWizard',
> +    mixins: ['Proxmox.Mixin.CBind'],
> +
> +    viewModel: {
> +	data: {
> +	    nodename: '',
> +	    current: {
> +		scsihw: '',
> +	    },
> +	},
> +    },
> +
> +    cbindData: {
> +	nodename: undefined,
> +    },
> +
> +    subject: gettext('Import Virtual Machine'),
> +
> +    isImport: true,
> +
> +    addDisk: function() {
> +	const me = this;
> +	const wizard = me.xtype === 'pveQemuImportWizard' ? me : me.up('window');
> +	wizard.down('pveQemuHDInputPanelCollection').addDisk();
> +    },
> +
> +    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: [
> +		{
> +		    xtype: 'label',
> +		    itemId: 'successTextfield',
> +		    hidden: true,
> +		    html: gettext('Manifest successfully uploaded'),
> +		    margin: '0 0 0 10',
> +		},
> +		{
> +		    xtype: 'textfield',
> +		    name: 'ovfTextfield',
> +		    emptyText: '/mnt/nfs/exported.ovf',
> +		    fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
> +		    listeners: {
> +			validitychange: function(_, isValid) {
> +			    const button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop();
> +			    button.setDisabled(!isValid);
> +			},
> +		    },
> +		    validator: function(value) {
> +			return (value && value.startsWith('/')) || gettext("Must start with /");
> +		    },
> +		},
> +		{
> +		    xtype: 'proxmoxButton',
> +		    itemId: 'load_remote_manifest_button',
> +		    text: gettext('Load remote manifest'),
> +		    disabled: true,
> +		    handler: function() {
> +			const inputpanel = this.up('#importInputpanel');
> +			const nodename = inputpanel.down('pveNodeSelector').getValue();
> +			const ovfTextfieldValue = inputpanel.down('textfield[name=ovfTextfield]').getValue();
> +			const wizard = this.up('window');
> +			Proxmox.Utils.API2Request({
> +			    url: '/nodes/' + nodename + '/readovf',
> +			    method: 'GET',
> +			    params: {
> +				manifest: ovfTextfieldValue,
> +			    },
> +			    success: function(response) {
> +				const 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;
> +				const devices = Object.keys(ovfdata); // e.g. ide0, sata2
> +				const hdcollection = wizard.down('pveQemuHDInputPanelCollection');
> +				hdcollection.removeAllDisks(); // does nothing if already empty
> +				devices.forEach(device => hdcollection.addDisk(device, ovfdata[device]));
> +			    },
> +			    failure: function(response) {
> +				Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +			    },
> +			});
> +		    },
> +		},
> +	    ],
> +	    onGetValues: function(values) {
> +		delete values.ovfTextfield;
> +		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: 'pveQemuHDInputPanelCollection',
> +	    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 params = wizard.getValues();
> +
> +		var nodename = params.nodename;
> +		delete params.nodename;
> +		delete params.delete;
> +		if (Array.isArray(params.import_sources)) {
> +		    params.import_sources = params.import_sources.join('\0');
> +		}
> +
> +		Proxmox.Utils.API2Request({
> +		    url: `/nodes/${nodename}/qemu`,
> +		    waitMsgTarget: wizard,
> +		    method: 'POST',
> +		    params: params,
> +		    success: function() {
> +			wizard.close();
> +		    },
> +		    failure: function(response) {
> +			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +		    },
> +		});
> +	    },
> +	},
> +    ],
> +});
> diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
> index 8b930bbd..a3e3b690 100644
> --- a/www/manager6/window/Wizard.js
> +++ b/www/manager6/window/Wizard.js
> @@ -261,6 +261,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
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel




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

* Re: [pve-devel] [PATCH] Add API for VM import
  2021-04-12 10:08 [pve-devel] [PATCH] Add API for VM import Dominic Jäger
  2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger
@ 2021-04-22 20:06 ` Thomas Lamprecht
  1 sibling, 0 replies; 4+ messages in thread
From: Thomas Lamprecht @ 2021-04-22 20:06 UTC (permalink / raw)
  To: Proxmox VE development discussion, Dominic Jäger

On 12.04.21 12:08, Dominic Jäger wrote:
> Extend qm importdisk/importovf functionality to the API.
> 
> Co-authored-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
> Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
> ---
> v8:
> - Fabian moved the import functions into the existing create_vm / update_vm_api
> - Dropped the separate API endpoints & import lock
> 

for the record, we talked for postponing this to 7.0 to improve convergence/synergy
(gosh, feels like marketing with those terms ;)) between the create and import
wizard.





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

end of thread, other threads:[~2021-04-22 20:06 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2021-04-12 10:08 [pve-devel] [PATCH] Add API for VM import Dominic Jäger
2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger
2021-04-13 10:11   ` Oguz Bektas
2021-04-22 20:06 ` [pve-devel] [PATCH] Add API for VM import Thomas Lamprecht

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal