all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [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 ++
 www/manager6/node/CmdMenu.js            |  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;
 
diff --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 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