* [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import @ 2021-02-05 10:04 Dominic Jäger 2021-02-05 10:04 ` [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM Dominic Jäger 2021-02-10 9:40 ` [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Fabian Grünbichler 0 siblings, 2 replies; 9+ messages in thread From: Dominic Jäger @ 2021-02-05 10:04 UTC (permalink / raw) To: pve-devel Extend qm importdisk/importovf functionality to the API. qm can be adapted to use this later. Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com> --- Biggest v3->v4 changes: * New code instead of bloating update_vm_api * Don't change anything in the existing schema, use new parameter "diskimages" * Because this can happen later: - Only root can use this - Don't touch qm (yet) PVE/API2/Qemu.pm | 375 +++++++++++++++++++++++++++++++++++++++++- PVE/QemuServer.pm | 16 +- PVE/QemuServer/OVF.pm | 10 +- 3 files changed, 394 insertions(+), 7 deletions(-) diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm index 3571f5e..1ed763b 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); @@ -4325,4 +4324,378 @@ __PACKAGE__->register_method({ return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type}); }}); +# Raise exception if $format is not supported by $storageid +my $check_format_is_supported = sub { + my ($format, $storageid) = @_; + + return if !$format; + + my $store_conf = PVE::Storage::config(); + my (undef, $valid_formats) = PVE::Storage::storage_default_format($store_conf, $storageid); + my $supported = grep { $_ eq $format } @$valid_formats; + + if (!$supported) { + raise_param_exc({format => "$format is not supported on storage $storageid"}); + } +}; + +# paths are returned as is +# volids are returned as paths +# +# Also checks if $original actually exists +my $convert_to_path = sub { + my ($original) = @_; + my $volid_as_path = eval { # Nonempty iff $original_source is a volid + PVE::Storage::path(PVE::Storage::config(), $original); + }; + my $result = $volid_as_path || $original ; + if (!-e $result) { + die "Could not import because source '$original' does not exist!"; + } + return $result; +}; + +# vmid ... target VM ID +# source ... absolute path of the source image (volid must be converted before) +# storage ... target storage for the disk image +# format ... target format for the disk image (optional) +# +# returns ... volid of the allocated disk image (e.g. local-lvm:vm-100-disk-2) +my $import_disk_image = sub { + my ($param) = @_; + my $vmid = $param->{vmid}; + my $requested_format = $param->{format}; + my $storage = $param->{storage}; + my $source = $param->{source}; + + my $vm_conf = PVE::QemuConfig->load_config($vmid); + my $store_conf = PVE::Storage::config(); + if (!$source) { + die "It is necessary to pass the source parameter"; + } + if ($source !~ m!^/!) { + die "source must be an absolute path but is $source"; + } + if (!-e $source) { + die "Could not import because source $source does not exist!"; + } + if (!$storage) { + die "It is necessary to pass the storage parameter"; + } + + print "Importing disk image '$source'...\n"; + + my $src_size = PVE::Storage::file_size_info($source); + if (!defined($src_size)) { + die "Could not get file size of $source"; + } elsif (!$src_size) { + die "Size of file $source is 0"; + } elsif ($src_size==1) { + die "Cannot import a directory"; + } + + $check_format_is_supported->($requested_format, $storage); + + my $dst_format = PVE::QemuServer::resolve_dst_disk_format( + $store_conf, $storage, undef, $requested_format); + my $dst_volid = PVE::Storage::vdisk_alloc($store_conf, $storage, + $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($source, $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 '$source' 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.", + 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', + format => 'pve-volume-id-or-absolute-path', + }, + 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', + description => "Options to set for the new disk ". + "(e.g. 'discard=on,backup=0')", + optional => 1, + requires => 'device', + }, + 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'), + }, + }, + returns => { type => 'string'}, + code => sub { + my ($param) = @_; + my $vmid = extract_param($param, 'vmid'); + my $node = extract_param($param, 'node'); + 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'); + my $storecfg = PVE::Storage::config(); + my $storeid = extract_param($param, 'storage'); + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + + my $format_explicit = extract_param($param, 'format'); + my $format_device_option; + if ($device_options) { + $device_options =~ m/format=([^,]*)/; + $format_device_option = $1; + if ($format_explicit && $format_device_option) { + raise_param_exc({format => "Disk format may be specified only once!"}); + } + } + my $format = $format_explicit || $format_device_option; + $check_format_is_supported->($format, $storeid); + + my $locked = sub { + my $conf = PVE::QemuConfig->load_config($vmid); + PVE::Tools::assert_if_modified($conf->{digest}, $digest); + + if ($device && $conf->{$device}) { + die "Could not import because device $device is already in ". + "use in VM $vmid. Choose a different device!"; + } + + my $imported_volid = $import_disk_image->({ + vmid => $vmid, + source => $convert_to_path->($original_source), + storage => $storeid, + format => $format, + }); + + my $volid = $imported_volid; + if ($device) { + # Attach with specified options + $volid .= ",${device_options}" if $device_options; + } else { + # Add as unused to config + $device = PVE::QemuConfig->add_unused_volume($conf, $imported_volid); + } + $update_vm_api->({ + node => $node, + vmid => $vmid, + $device => $volid, + }); + }; + my $worker = sub { + PVE::QemuConfig->lock_config_full($vmid, 1, $locked); + }; + return $rpcenv->fork_worker('importdisk', $vmid, $authuser, $worker); + }}); + +__PACKAGE__->register_method({ + name => 'importvm', + path => '{vmid}/importvm', + method => 'POST', + description => "Import a VM from existing disk images.", + protected => 1, + proxyto => 'node', + parameters => { + additionalProperties => 0, + properties => PVE::QemuServer::json_config_properties( + { + node => get_standard_option('pve-node'), + vmid => get_standard_option('pve-vmid', { completion => \&PVE::Cluster::complete_next_vmid }), + diskimages => { + description => "Mapping of devices to disk images." . + "For example, scsi0:/mnt/nfs/image1.vmdk,scsi1:/mnt/nfs/image2", + type => 'string', + }, + start => { + optional => 1, + type => 'boolean', + default => 0, + description => "Start VM after it was imported successfully.", + }, + }), + }, + returns => { + type => 'string', + }, + code => sub { + my ($param) = @_; + my $node = extract_param($param, 'node'); + my $vmid = extract_param($param, 'vmid'); + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $storecfg = PVE::Storage::config(); + + PVE::Cluster::check_cfs_quorum(); + + my $diskimages_string = extract_param($param, 'diskimages'); + my @diskimage_pairs = split(',', $diskimages_string); + + my $use_import = sub { + my ($opt) = @_; + return 0 if $opt eq 'efidisk0'; + return PVE::QemuServer::Drive::is_valid_drivename($opt); + }; + + my $msg = "There must be exactly as many devices specified as there " . + " are devices in the diskimage parameter.\n For example for " . + "--scsi0 local-lvm:0,discard=on --scsi1 local:0,cache=unsafe " . + "there must be --diskimages scsi0=/source/path,scsi1=/other/path"; + my $device_count = grep { $use_import->($_) } keys %$param; + + my $diskimages_count = @diskimage_pairs; + if ($device_count != $diskimages_count) { + raise_param_exc({diskimages => $msg}); + } + + my $diskimages = {}; + foreach ( @diskimage_pairs ) { + my ($device, $diskimage) = split('=', $_); + $diskimages->{$device} = $diskimage; + } + + my $worker = sub { + eval { PVE::QemuConfig->create_and_lock_config($vmid, 0, 'import') }; + die "Unable to create config for VM import: $@" if $@; + + my @volids_of_imported = (); + eval { foreach my $opt (keys %$param) { + next if ($opt eq 'start'); + + my $updated_value; + if ($use_import->($opt)) { + # $opt is bus/device like ide0, scsi5 + + my $device = PVE::QemuServer::parse_drive($opt, $param->{$opt}); + raise_param_exc({ $opt => "Unable to parse drive options" }) + if !$device; + + my $source_path = $convert_to_path->($diskimages->{$opt}); + + $param->{$opt} =~ m/format=([^,]*)/; + my $format = $1; + + my $imported_volid = $import_disk_image->({ + vmid => $vmid, + source => $source_path, + device => $opt, + storage => (split ':', $device->{file})[0], + format => $format, + }); + push @volids_of_imported, $imported_volid; + + # $param->{opt} has all required options but also dummy + # import 0 instead of the image + # for example, local-lvm:0,discard=on,mbps_rd=100 + my $volid = $param->{$opt}; + # Replace 0 with allocated volid, for example + # local-lvm:vm-100-disk-2,discard=on,mbps_rd=100 + $volid =~ s/^.*?,/$imported_volid,/; + + $updated_value = $volid; + } else { + $updated_value = $param->{$opt}; + } + $update_vm_api->( + { + node => $node, + vmid => $vmid, + $opt => $updated_value, + skiplock => 1, + }, + 1, # avoid nested workers that only do a short operation + ); + }}; + + my $conf = PVE::QemuConfig->load_config($vmid); + my $bootdevs = PVE::QemuServer::get_default_bootdevices($conf); + $update_vm_api->( + { + node => $node, + vmid => $vmid, + boot => PVE::QemuServer::print_bootorder($bootdevs), + skiplock => 1, + }, + 1, + ); + + my $err = $@; + if ($err) { + foreach my $volid (@volids_of_imported) { + eval { PVE::Storage::vdisk_free($storecfg, $volid) }; + warn $@ if $@; + } + + eval { + my $conffile = PVE::QemuConfig->config_file($vmid); + unlink($conffile) or die "Failed to remove config file: $!\n"; + }; + warn $@ if $@; + + die $err; + } + + eval { PVE::QemuConfig->remove_lock($vmid, 'import') }; + warn $@ if $@; + + if ($param->{start}) { + PVE::QemuServer::vm_start($storecfg, $vmid); + } + }; + + return $rpcenv->fork_worker('importvm', $vmid, $authuser, $worker); + }}); + + 1; diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm index 9c65d76..c02f5eb 100644 --- a/PVE/QemuServer.pm +++ b/PVE/QemuServer.pm @@ -300,7 +300,7 @@ my $confdesc = { optional => 1, type => 'string', description => "Lock/unlock the VM.", - enum => [qw(backup clone create migrate rollback snapshot snapshot-delete suspending suspended)], + enum => [qw(backup clone create migrate rollback snapshot snapshot-delete suspending suspended import)], }, cpulimit => { optional => 1, @@ -998,6 +998,18 @@ 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) = @_; + + # Exactly these 2 are allowed in id_or_qm_path but should not be allowed here + if ($volid eq 'none' || $volid eq 'cdrom') { + return undef if $noerr; + die "Invalid format! Should be volume ID or absolute path."; + } + return verify_volume_id_or_qm_path($volid, $noerr); +} + my $usb_fmt = { host => { default_key => 1, @@ -6659,7 +6671,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 required to import from LVM images $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..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] 9+ messages in thread
* [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM 2021-02-05 10:04 [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Dominic Jäger @ 2021-02-05 10:04 ` Dominic Jäger 2021-02-10 9:49 ` Fabian Grünbichler 2021-02-10 9:40 ` [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Fabian Grünbichler 1 sibling, 1 reply; 9+ messages in thread From: Dominic Jäger @ 2021-02-05 10:04 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> --- The wizard works, but there is still quite some refactoring to do v3->v4: * Allow only root * Adapt to API changes PVE/API2/Nodes.pm | 40 +++ 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 | 194 ++++++++++++- www/manager6/qemu/HardwareView.js | 25 ++ www/manager6/qemu/ImportWizard.js | 356 ++++++++++++++++++++++++ www/manager6/qemu/MultiHDEdit.js | 282 +++++++++++++++++++ www/manager6/window/Wizard.js | 2 + 10 files changed, 930 insertions(+), 14 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..9bf75ab7 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,44 @@ __PACKAGE__->register_method ({ return undef; }}); +__PACKAGE__->register_method ({ + name => 'readovf', + path => 'readovf', + 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", + }, + 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; +}}); + # 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 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..8e9aee98 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+)/; + let [_, 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..407cf2d0 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() { + 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 e22111bf..5d039134 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,8 +95,12 @@ 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 + me.test = `test`; } else { me.drive.file = values.hdstorage + ":" + values.disksize; } @@ -83,16 +114,31 @@ 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.diskimages = [confid, me.drive.importsource].join('='); + } + delete me.drive.importsource; + params[confid] = PVE.Parser.printQemuDrive(me.drive); + } else { + delete me.drive.file; + 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 +195,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 +220,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 +243,7 @@ Ext.define('PVE.qemu.HDInputPanel', { submitValue: false, hidden: true, }); - me.column1.push(me.scsiController); + controllerColumn.push(me.scsiController); } if (me.unused) { @@ -199,14 +258,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, - }); + 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 +283,12 @@ Ext.define('PVE.qemu.HDInputPanel', { }); } + if (me.showSourcePathTextfield) { + me.column2.push({ + xtype: 'box', + autoEl: { tag: 'hr' }, + }); + } me.column2.push( { xtype: 'CacheTypeSelector', @@ -231,6 +303,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( { @@ -373,13 +529,20 @@ Ext.define('PVE.qemu.HDEdit', { nodename: nodename, unused: unused, 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 +567,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 77640e53..aeb2b762 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -436,6 +436,30 @@ 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('#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 +776,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..2aabe74e --- /dev/null +++ b/www/manager6/qemu/ImportWizard.js @@ -0,0 +1,356 @@ +/*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', + listeners: { + validitychange: function(_, isValid) { + let button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop(); + button.setDisabled(!isValid); + }, + }, + validator: function(value) { + if (value && !value.startsWith('/')) { + return gettext("Must start with /"); + } + return true; + }, + }, + { + xtype: 'proxmoxButton', + itemId: 'load_remote_manifest_button', + text: gettext('Load remote manifest'), + disabled: true, + 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) { + multihd.addDiskFunction(device, ovfdata[device]); + } + }, + 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 params = wizard.getValues(); + + var nodename = params.nodename; + delete params.nodename; + delete params.delete; + if (Array.isArray(params.diskimages)) { + params.diskimages = params.diskimages.join(','); + } + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/qemu/${params.vmid}/importvm`, + 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/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js new file mode 100644 index 00000000..403ad6df --- /dev/null +++ b/www/manager6/qemu/MultiHDEdit.js @@ -0,0 +1,282 @@ +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 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(); + } + + let sp = Ext.state.Manager.getProvider(); + let advanced_checkbox = sp.get('proxmox-advanced-cb'); + added.setAdvancedVisible(advanced_checkbox); + + 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 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] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM 2021-02-05 10:04 ` [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM Dominic Jäger @ 2021-02-10 9:49 ` Fabian Grünbichler 2021-03-08 11:38 ` Dominic Jäger 0 siblings, 1 reply; 9+ messages in thread From: Fabian Grünbichler @ 2021-02-10 9:49 UTC (permalink / raw) To: Proxmox VE development discussion haven't taken a closer look at the GUI stuff as I think the backend will potentially change a bit more. also regarding the permissions and the problem of importing from arbitrary paths, I wonder whether a simple file upload to a dir storage to import/$authuser/$file if the user has permissions to allocate space on the storage would help? those files could then be referenced for import purposes by that user without needing root privileges. and we just pass them to qemu-img convert which would make the attack surface not that high hopefully (barring issues in the handling of formats, but we could just allow raw to avoid that in the beginning ;)) anyway, might be a nice follow-up once this series has landed.. On February 5, 2021 11:04 am, 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> > --- > The wizard works, but there is still quite some refactoring to do > > v3->v4: > * Allow only root > * Adapt to API changes > > > PVE/API2/Nodes.pm | 40 +++ > 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 | 194 ++++++++++++- > www/manager6/qemu/HardwareView.js | 25 ++ > www/manager6/qemu/ImportWizard.js | 356 ++++++++++++++++++++++++ > www/manager6/qemu/MultiHDEdit.js | 282 +++++++++++++++++++ > www/manager6/window/Wizard.js | 2 + > 10 files changed, 930 insertions(+), 14 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..9bf75ab7 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,44 @@ __PACKAGE__->register_method ({ > return undef; > }}); this API endpoint belongs in qemu-server? > > +__PACKAGE__->register_method ({ > + name => 'readovf', > + path => 'readovf', > + 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", according to the code below, this has a defined schema? > + }, > + 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; > +}}); > + > # 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 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..8e9aee98 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+)/; > + let [_, 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..407cf2d0 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() { > + 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 e22111bf..5d039134 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,8 +95,12 @@ 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 > + me.test = `test`; > } else { > me.drive.file = values.hdstorage + ":" + values.disksize; > } > @@ -83,16 +114,31 @@ 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.diskimages = [confid, me.drive.importsource].join('='); > + } > + delete me.drive.importsource; > + params[confid] = PVE.Parser.printQemuDrive(me.drive); > + } else { > + delete me.drive.file; > + 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 +195,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 +220,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 +243,7 @@ Ext.define('PVE.qemu.HDInputPanel', { > submitValue: false, > hidden: true, > }); > - me.column1.push(me.scsiController); > + controllerColumn.push(me.scsiController); > } > > if (me.unused) { > @@ -199,14 +258,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, > - }); > + 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 +283,12 @@ Ext.define('PVE.qemu.HDInputPanel', { > }); > } > > + if (me.showSourcePathTextfield) { > + me.column2.push({ > + xtype: 'box', > + autoEl: { tag: 'hr' }, > + }); > + } > me.column2.push( > { > xtype: 'CacheTypeSelector', > @@ -231,6 +303,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( > { > @@ -373,13 +529,20 @@ Ext.define('PVE.qemu.HDEdit', { > nodename: nodename, > unused: unused, > 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 +567,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 77640e53..aeb2b762 100644 > --- a/www/manager6/qemu/HardwareView.js > +++ b/www/manager6/qemu/HardwareView.js > @@ -436,6 +436,30 @@ 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('#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 +776,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..2aabe74e > --- /dev/null > +++ b/www/manager6/qemu/ImportWizard.js > @@ -0,0 +1,356 @@ > +/*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', > + listeners: { > + validitychange: function(_, isValid) { > + let button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop(); > + button.setDisabled(!isValid); > + }, > + }, > + validator: function(value) { > + if (value && !value.startsWith('/')) { > + return gettext("Must start with /"); > + } > + return true; > + }, > + }, > + { > + xtype: 'proxmoxButton', > + itemId: 'load_remote_manifest_button', > + text: gettext('Load remote manifest'), > + disabled: true, > + 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) { > + multihd.addDiskFunction(device, ovfdata[device]); > + } > + }, > + 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 params = wizard.getValues(); > + > + var nodename = params.nodename; > + delete params.nodename; > + delete params.delete; > + if (Array.isArray(params.diskimages)) { > + params.diskimages = params.diskimages.join(','); > + } > + > + Proxmox.Utils.API2Request({ > + url: `/nodes/${nodename}/qemu/${params.vmid}/importvm`, > + 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/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js > new file mode 100644 > index 00000000..403ad6df > --- /dev/null > +++ b/www/manager6/qemu/MultiHDEdit.js > @@ -0,0 +1,282 @@ > +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 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(); > + } > + > + let sp = Ext.state.Manager.getProvider(); > + let advanced_checkbox = sp.get('proxmox-advanced-cb'); > + added.setAdvancedVisible(advanced_checkbox); > + > + 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 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] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM 2021-02-10 9:49 ` Fabian Grünbichler @ 2021-03-08 11:38 ` Dominic Jäger 2021-03-08 12:39 ` Fabian Grünbichler 0 siblings, 1 reply; 9+ messages in thread From: Dominic Jäger @ 2021-03-08 11:38 UTC (permalink / raw) To: Proxmox VE development discussion On Wed, Feb 10, 2021 at 10:49:41AM +0100, Fabian Grünbichler wrote: > > > > diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm > > index 8172231e..9bf75ab7 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,44 @@ __PACKAGE__->register_method ({ > > return undef; > > }}); > > this API endpoint belongs in qemu-server? I am not sure. It looked to me like qemu-server implied nodes/<node>/qemu/<vmid>/readovf but I wanted to avoid <vmid> and place it next to nodes/<node>/status, nodes/<node>/rrd, ... which are in this file? > > > > +__PACKAGE__->register_method ({ > > + name => 'readovf', > > + path => 'readovf', > > + 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", > > according to the code below, this has a defined schema? Yes. Something like returns => { type => 'object', additionalProperties => 0, properties => { name => { type => 'string', optional => 1, }, cores => { type => 'integer', optional => 1, }, memory => { type => 'integer', optional => 1, }, ?? => { type => 'string', description => 'path of a disk image', }, ?? => { type => 'string', description => 'path of a disk image', }, .... }, but the only way I have found so far to return paths scsi1 => /some/image.img sata3 => /some/other.img is PVE::QemuServer::json_config_properties( which allows much more than can be read from the OVF at the moment. > > > + }, > > + 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; > > +}}); > > + > > # bash completion helper > > > > sub complete_templet_repo { ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM 2021-03-08 11:38 ` Dominic Jäger @ 2021-03-08 12:39 ` Fabian Grünbichler 0 siblings, 0 replies; 9+ messages in thread From: Fabian Grünbichler @ 2021-03-08 12:39 UTC (permalink / raw) To: Dominic Jäger, Proxmox VE development discussion On March 8, 2021 12:38 pm, Dominic Jäger wrote: > On Wed, Feb 10, 2021 at 10:49:41AM +0100, Fabian Grünbichler wrote: >> > >> > diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm >> > index 8172231e..9bf75ab7 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,44 @@ __PACKAGE__->register_method ({ >> > return undef; >> > }}); >> >> this API endpoint belongs in qemu-server? > > > I am not sure. It looked to me like qemu-server implied > nodes/<node>/qemu/<vmid>/readovf > but I wanted to avoid <vmid> and place it next to > nodes/<node>/status, nodes/<node>/rrd, ... > which are in this file? qemu-server also ships API paths besides /nodes/<node>/qemu/*, e.g. PVE::API2::Qemu::CPU provides /nodes/<node>/cpu, so we might do a /nodes/<node>/ovf (or something similar under /cluster, if it's not at all node-dependent). >> > >> > +__PACKAGE__->register_method ({ >> > + name => 'readovf', >> > + path => 'readovf', >> > + 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", >> >> according to the code below, this has a defined schema? > > Yes. Something like > > returns => { > type => 'object', > additionalProperties => 0, > properties => { > name => { > type => 'string', > optional => 1, > }, > cores => { > type => 'integer', > optional => 1, > }, > memory => { > type => 'integer', > optional => 1, > }, > ?? => { > type => 'string', > description => 'path of a disk image', > }, > ?? => { > type => 'string', > description => 'path of a disk image', > }, > .... > }, > > but the only way I have found so far to return paths > scsi1 => /some/image.img > sata3 => /some/other.img > > is PVE::QemuServer::json_config_properties( which allows much more than can be > read from the OVF at the moment. you can just have your own helper that uses the existing valid disk names helper, takes the static part of the return schema and generates the full return schema. >> >> > + }, >> > + 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; >> > +}}); >> > + >> > # bash completion helper >> > >> > sub complete_templet_repo { > ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import 2021-02-05 10:04 [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Dominic Jäger 2021-02-05 10:04 ` [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM Dominic Jäger @ 2021-02-10 9:40 ` Fabian Grünbichler 2021-02-11 10:32 ` Dominic Jäger 1 sibling, 1 reply; 9+ messages in thread From: Fabian Grünbichler @ 2021-02-10 9:40 UTC (permalink / raw) To: Proxmox VE development discussion high level review: this is starting to take shape :) I wonder whether it doesn't make sense to add proper permissions already? most of it is handled in PVE::Storage::check_volume_access or check_storage_access in this API module already.. the latter could be extended to handle the new special syntax similary to NEW_DISK_RE, and we'd basically have almost no special casing left.. I'm also a bit on the fence whether we really want a separate API call or just a new parameter for the importvm feature. with the switch to a single parameter for the key to source mapping and some of the changes below it might be rather straight-forward - I might try to take your next version and refactor it to be in-line with the regular create_vm to see which variant looks better. just as a heads-up ;) On February 5, 2021 11:04 am, Dominic Jäger wrote: > Extend qm importdisk/importovf functionality to the API. > qm can be adapted to use this later. > > Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com> > --- > Biggest v3->v4 changes: > * New code instead of bloating update_vm_api > * Don't change anything in the existing schema, use new parameter "diskimages" > * Because this can happen later: > - Only root can use this > - Don't touch qm (yet) > > > PVE/API2/Qemu.pm | 375 +++++++++++++++++++++++++++++++++++++++++- > PVE/QemuServer.pm | 16 +- > PVE/QemuServer/OVF.pm | 10 +- > 3 files changed, 394 insertions(+), 7 deletions(-) > > diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm > index 3571f5e..1ed763b 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); > > @@ -4325,4 +4324,378 @@ __PACKAGE__->register_method({ > return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type}); > }}); > > +# Raise exception if $format is not supported by $storageid > +my $check_format_is_supported = sub { > + my ($format, $storageid) = @_; > + > + return if !$format; > + > + my $store_conf = PVE::Storage::config(); it probably makes sense to pass this in as parameter, all call sites probably have it already ;) > + my (undef, $valid_formats) = PVE::Storage::storage_default_format($store_conf, $storageid); > + my $supported = grep { $_ eq $format } @$valid_formats; > + > + if (!$supported) { > + raise_param_exc({format => "$format is not supported on storage $storageid"}); I think a regular die here makes more sense (one of the two call-sites you introduce can have a different parameter than 'format' as source of the value being checked - that would be rather confusing..). it might make it usable for other call-sites as well then. where applicable, you can always raise a param exception with $@ as message ;) > + } > +}; > + > +# paths are returned as is > +# volids are returned as paths > +# > +# Also checks if $original actually exists > +my $convert_to_path = sub { > + my ($original) = @_; > + my $volid_as_path = eval { # Nonempty iff $original_source is a volid > + PVE::Storage::path(PVE::Storage::config(), $original); > + }; > + my $result = $volid_as_path || $original ; > + if (!-e $result) { > + die "Could not import because source '$original' does not exist!"; > + } > + return $result; > +}; what does this do that PVE::Storage::abs_filesystem_path doesn't already do (except having 'import' in the error message ;))? ah, further down below I see that -b might be missing for raw block devices.. maybe it makes sense to allow those in that helper as well? optionally? call-sites would need to be checked.. > + > +# vmid ... target VM ID > +# source ... absolute path of the source image (volid must be converted before) that restriction is not actually needed, see below > +# storage ... target storage for the disk image > +# format ... target format for the disk image (optional) > +# > +# returns ... volid of the allocated disk image (e.g. local-lvm:vm-100-disk-2) > +my $import_disk_image = sub { > + my ($param) = @_; > + my $vmid = $param->{vmid}; > + my $requested_format = $param->{format}; > + my $storage = $param->{storage}; > + my $source = $param->{source}; > + > + my $vm_conf = PVE::QemuConfig->load_config($vmid); could be passed in, the call sites already have it.. and it might allow to pull in some of the surrounding codes at the call sites that is similar (config updating, property string building) into this helper > + my $store_conf = PVE::Storage::config(); could also be passed in here > + if (!$source) { > + die "It is necessary to pass the source parameter"; > + } just a check for definedness > + if ($source !~ m!^/!) { > + die "source must be an absolute path but is $source"; > + } > + if (!-e $source) { > + die "Could not import because source $source does not exist!"; > + } and another call to abs_filesystem_path would do all these checks, and support passing in regular volumes as source as well. > + if (!$storage) { > + die "It is necessary to pass the storage parameter"; > + } call PVE::Storage::storage_config() here to get both that and the check that the storage exists.. > + > + print "Importing disk image '$source'...\n"; > + > + my $src_size = PVE::Storage::file_size_info($source); > + if (!defined($src_size)) { > + die "Could not get file size of $source"; > + } elsif (!$src_size) { > + die "Size of file $source is 0"; > + } elsif ($src_size==1) { > + die "Cannot import a directory"; can't happen if abs_filesystem_path was used above - that only works for volumes or files ;) > + } > + > + $check_format_is_supported->($requested_format, $storage); > + > + my $dst_format = PVE::QemuServer::resolve_dst_disk_format( > + $store_conf, $storage, undef, $requested_format); > + my $dst_volid = PVE::Storage::vdisk_alloc($store_conf, $storage, > + $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($source, $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 '$source' failed: $err\n" if $err; > + } > + > + return $dst_volid; > +}; > + > +__PACKAGE__->register_method ({ > + name => 'importdisk', > + path => '{vmid}/importdisk', > + method => 'POST', > + protected => 1, # for worker upid file huh? no - because we need elevated privileges to allocate disks on storages.. > + proxyto => 'node', > + description => "Import an external disk image into a VM. The image format ". > + "has to be supported by qemu-img.", > + 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 ". how does the second work here? and the whole thing is root-only, so that qualifier at the end is redundant ;) > + "path on the server.", > + type => 'string', > + format => 'pve-volume-id-or-absolute-path', > + }, > + 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', > + description => "Options to set for the new disk ". > + "(e.g. 'discard=on,backup=0')", > + optional => 1, > + requires => 'device', > + }, > + 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, > + }, not directly related to this series, but this enum is copied all over the place and might be defined in one place as standard option (qm-new-disk-format ?) > + digest => get_standard_option('pve-config-digest'), > + }, > + }, > + returns => { type => 'string'}, > + code => sub { > + my ($param) = @_; > + my $vmid = extract_param($param, 'vmid'); > + my $node = extract_param($param, 'node'); > + my $original_source = extract_param($param, 'source'); not sure why this gets this name, it's just passed once and not used afterwards.. also, see comments above about how to make this more flexible.. > + my $digest = extract_param($param, 'digest'); > + my $device_options = extract_param($param, 'device_options'); IMHO this one needs to be parsed - e.g., by adding a fake volume with the syntax used in importvm at the front and parsing it according to the disk schema.. both to catch bogus stuff early, and to make the handling below more robust w.r.t. future changes.. > + my $device = extract_param($param, 'device'); > + my $storecfg = PVE::Storage::config(); > + my $storeid = extract_param($param, 'storage'); > + > + my $rpcenv = PVE::RPCEnvironment::get(); > + my $authuser = $rpcenv->get_user(); > + > + my $format_explicit = extract_param($param, 'format'); > + my $format_device_option; > + if ($device_options) { > + $device_options =~ m/format=([^,]*)/; > + $format_device_option = $1; e.g. here we'd just check $device_options->{format} > + if ($format_explicit && $format_device_option) { > + raise_param_exc({format => "Disk format may be specified only once!"}); format already specified in device_options? > + } > + } > + my $format = $format_explicit || $format_device_option; since both of those are not needed after this point, this could just be returned / set by the above if, dropping the extra variables in the outer scope.. > + $check_format_is_supported->($format, $storeid); > + > + my $locked = sub { > + my $conf = PVE::QemuConfig->load_config($vmid); > + PVE::Tools::assert_if_modified($conf->{digest}, $digest); > + > + if ($device && $conf->{$device}) { > + die "Could not import because device $device is already in ". > + "use in VM $vmid. Choose a different device!"; both of these checks might make sense outside of the worker already to give immediate feedback in the API response.. > + } > + > + my $imported_volid = $import_disk_image->({ > + vmid => $vmid, > + source => $convert_to_path->($original_source), > + storage => $storeid, > + format => $format, > + }); so this could become my $target = { storage => $storeid, format => $format, options => $device_options, device => $device, }; $import_disk_image($storecfg, $vmid, $conf, $source, $target) > + > + my $volid = $imported_volid; > + if ($device) { > + # Attach with specified options > + $volid .= ",${device_options}" if $device_options; > + } else { > + # Add as unused to config > + $device = PVE::QemuConfig->add_unused_volume($conf, $imported_volid); > + } > + $update_vm_api->({ > + node => $node, > + vmid => $vmid, > + $device => $volid, > + }); and all of that could move inside the helper > + }; > + my $worker = sub { > + PVE::QemuConfig->lock_config_full($vmid, 1, $locked); > + }; > + return $rpcenv->fork_worker('importdisk', $vmid, $authuser, $worker); > + }}); > + > +__PACKAGE__->register_method({ > + name => 'importvm', > + path => '{vmid}/importvm', > + method => 'POST', > + description => "Import a VM from existing disk images.", > + protected => 1, > + proxyto => 'node', > + parameters => { > + additionalProperties => 0, > + properties => PVE::QemuServer::json_config_properties( > + { > + node => get_standard_option('pve-node'), > + vmid => get_standard_option('pve-vmid', { completion => \&PVE::Cluster::complete_next_vmid }), > + diskimages => { > + description => "Mapping of devices to disk images." . > + "For example, scsi0:/mnt/nfs/image1.vmdk,scsi1:/mnt/nfs/image2", > + type => 'string', > + }, > + start => { > + optional => 1, > + type => 'boolean', > + default => 0, > + description => "Start VM after it was imported successfully.", > + }, > + }), > + }, > + returns => { > + type => 'string', > + }, > + code => sub { > + my ($param) = @_; > + my $node = extract_param($param, 'node'); > + my $vmid = extract_param($param, 'vmid'); > + > + my $rpcenv = PVE::RPCEnvironment::get(); > + my $authuser = $rpcenv->get_user(); > + my $storecfg = PVE::Storage::config(); > + > + PVE::Cluster::check_cfs_quorum(); > + > + my $diskimages_string = extract_param($param, 'diskimages'); > + my @diskimage_pairs = split(',', $diskimages_string); > + > + my $use_import = sub { > + my ($opt) = @_; > + return 0 if $opt eq 'efidisk0'; > + return PVE::QemuServer::Drive::is_valid_drivename($opt); > + }; > + > + my $msg = "There must be exactly as many devices specified as there " . > + " are devices in the diskimage parameter.\n For example for " . > + "--scsi0 local-lvm:0,discard=on --scsi1 local:0,cache=unsafe " . > + "there must be --diskimages scsi0=/source/path,scsi1=/other/path"; > + my $device_count = grep { $use_import->($_) } keys %$param; why though? isn't it sufficient to say there must be a matching device for each diskimages entry and vice-versa? while doing the matching you could also check that the file exists (as per the comments above ;)) > + > + my $diskimages_count = @diskimage_pairs; > + if ($device_count != $diskimages_count) { > + raise_param_exc({diskimages => $msg}); > + } > + > + my $diskimages = {}; > + foreach ( @diskimage_pairs ) { > + my ($device, $diskimage) = split('=', $_); > + $diskimages->{$device} = $diskimage; > + } > + > + my $worker = sub { > + eval { PVE::QemuConfig->create_and_lock_config($vmid, 0, 'import') }; > + die "Unable to create config for VM import: $@" if $@; should happen outside the worker (so that a VMID clash is detected early). inside the worker you just lock and load, then check that the 'import' lock is still there.. we could also filter out disk parameters, and call update_vm_api with the rest here (those should be fast, but potentially in the future could run into permission issues that would be nice to return early before doing a massive disk conversion that has to be undone afterwards.. > + > + my @volids_of_imported = (); > + eval { foreach my $opt (keys %$param) { this could then just iterate over the disk parameters > + next if ($opt eq 'start'); > + > + my $updated_value; > + if ($use_import->($opt)) { > + # $opt is bus/device like ide0, scsi5 > + > + my $device = PVE::QemuServer::parse_drive($opt, $param->{$opt}); $drive? $device is used in this very patch for something else ;) > + raise_param_exc({ $opt => "Unable to parse drive options" }) > + if !$device; > + > + my $source_path = $convert_to_path->($diskimages->{$opt}); see other comments regarding this > + > + $param->{$opt} =~ m/format=([^,]*)/; > + my $format = $1; parse! also, if we allow mixing imports and newly-allocated empty or pre-existing volumes, check whether it's actually one we want to import here ;) > + > + my $imported_volid = $import_disk_image->({ > + vmid => $vmid, > + source => $source_path, > + device => $opt, > + storage => (split ':', $device->{file})[0], parse! > + format => $format, > + }); also see comment for the other call site about this helper's signature.. > + push @volids_of_imported, $imported_volid; > + > + # $param->{opt} has all required options but also dummy > + # import 0 instead of the image > + # for example, local-lvm:0,discard=on,mbps_rd=100 > + my $volid = $param->{$opt}; > + # Replace 0 with allocated volid, for example > + # local-lvm:vm-100-disk-2,discard=on,mbps_rd=100 > + $volid =~ s/^.*?,/$imported_volid,/; parse/print, move into helper.. > + > + $updated_value = $volid; > + } else { > + $updated_value = $param->{$opt}; > + } > + $update_vm_api->( > + { > + node => $node, > + vmid => $vmid, > + $opt => $updated_value, > + skiplock => 1, could just pass in the whole disk parameters instead, if we do the rest early.. > + }, > + 1, # avoid nested workers that only do a short operation > + ); > + }}; > + > + my $conf = PVE::QemuConfig->load_config($vmid); > + my $bootdevs = PVE::QemuServer::get_default_bootdevices($conf); > + $update_vm_api->( > + { > + node => $node, > + vmid => $vmid, > + boot => PVE::QemuServer::print_bootorder($bootdevs), > + skiplock => 1, > + }, > + 1, > + ); should only be done if boot is not already set.. > + > + my $err = $@; > + if ($err) { > + foreach my $volid (@volids_of_imported) { > + eval { PVE::Storage::vdisk_free($storecfg, $volid) }; > + warn $@ if $@; > + } > + > + eval { > + my $conffile = PVE::QemuConfig->config_file($vmid); > + unlink($conffile) or die "Failed to remove config file: $!\n"; > + }; > + warn $@ if $@; > + > + die $err; > + } > + > + eval { PVE::QemuConfig->remove_lock($vmid, 'import') }; > + warn $@ if $@; > + > + if ($param->{start}) { > + PVE::QemuServer::vm_start($storecfg, $vmid); > + } > + }; > + > + return $rpcenv->fork_worker('importvm', $vmid, $authuser, $worker); > + }}); > + > + > 1; > diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm > index 9c65d76..c02f5eb 100644 > --- a/PVE/QemuServer.pm > +++ b/PVE/QemuServer.pm > @@ -300,7 +300,7 @@ my $confdesc = { > optional => 1, > type => 'string', > description => "Lock/unlock the VM.", > - enum => [qw(backup clone create migrate rollback snapshot snapshot-delete suspending suspended)], > + enum => [qw(backup clone create migrate rollback snapshot snapshot-delete suspending suspended import)], > }, > cpulimit => { > optional => 1, > @@ -998,6 +998,18 @@ 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) = @_; > + > + # Exactly these 2 are allowed in id_or_qm_path but should not be allowed here > + if ($volid eq 'none' || $volid eq 'cdrom') { > + return undef if $noerr; > + die "Invalid format! Should be volume ID or absolute path."; > + } > + return verify_volume_id_or_qm_path($volid, $noerr); > +} > + > my $usb_fmt = { > host => { > default_key => 1, > @@ -6659,7 +6671,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 required to import from LVM images > $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..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 > > > _______________________________________________ > pve-devel mailing list > pve-devel@lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel > ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import 2021-02-10 9:40 ` [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Fabian Grünbichler @ 2021-02-11 10:32 ` Dominic Jäger 2021-02-12 9:03 ` Fabian Grünbichler 0 siblings, 1 reply; 9+ messages in thread From: Dominic Jäger @ 2021-02-11 10:32 UTC (permalink / raw) To: Proxmox VE development discussion Thank you for looking so carefully! On Wed, Feb 10, 2021 at 10:40:56AM +0100, Fabian Grünbichler wrote: > > On February 5, 2021 11:04 am, Dominic Jäger wrote: > > Extend qm importdisk/importovf functionality to the API. > > > > @@ -4325,4 +4324,378 @@ __PACKAGE__->register_method({ > > return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type}); > > }}); > > > > +# Raise exception if $format is not supported by $storageid > > +my $check_format_is_supported = sub { > > + my ($format, $storageid) = @_; > > + > > + return if !$format; > > + > > + my $store_conf = PVE::Storage::config(); > > it probably makes sense to pass this in as parameter, all call sites > probably have it already ;) I just noticed that I have it in importdisk, but unused. Does it make a relevant difference in terms of performance to call ::config() multiple times instead of passing it as parameter? > > > + } > > +}; > > + > > +# paths are returned as is > > +# volids are returned as paths > > +# > > +# Also checks if $original actually exists > > +my $convert_to_path = sub { > > + my ($original) = @_; > > + my $volid_as_path = eval { # Nonempty iff $original_source is a volid > > + PVE::Storage::path(PVE::Storage::config(), $original); > > + }; > > + my $result = $volid_as_path || $original ; > > + if (!-e $result) { > > + die "Could not import because source '$original' does not exist!"; > > + } > > + return $result; > > +}; > > what does this do that PVE::Storage::abs_filesystem_path doesn't already > do (except having 'import' in the error message ;))? Haven't thought about that function in the last time... I'll have a look at it. > ah, further down below I see that -b might be missing for raw block > devices.. maybe it makes sense to allow those in that helper as well? > optionally? call-sites would need to be checked.. Would it make sense to not change helper for the moment, certainly not break other call-sites and refactor later in a separate patch? > > > + > > +# vmid ... target VM ID > > +# source ... absolute path of the source image (volid must be converted before) > > that restriction is not actually needed, see below > > > +# storage ... target storage for the disk image > > +# format ... target format for the disk image (optional) > > +# > > +# returns ... volid of the allocated disk image (e.g. local-lvm:vm-100-disk-2) > > +my $import_disk_image = sub { > > + my ($param) = @_; > > + my $vmid = $param->{vmid}; > > + my $requested_format = $param->{format}; > > + my $storage = $param->{storage}; > > + my $source = $param->{source}; > > + > > + my $vm_conf = PVE::QemuConfig->load_config($vmid); > > could be passed in, the call sites already have it.. and it might allow > to pull in some of the surrounding codes at the call sites that is > similar (config updating, property string building) into this helper It's actually unused. I'll remove it. > > > + my $store_conf = PVE::Storage::config(); > > could also be passed in here * see below > > > + if (!$source) { > > + die "It is necessary to pass the source parameter"; > > + } > > just a check for definedness Hmm but if someone does $import_disk_image->({vmid => 102, source => "", storage => "local-lvm"}); then > > > + if ($source !~ m!^/!) { > > + die "source must be an absolute path but is $source"; will only say "... path but is at /usr/share/..."? > > > + if (!$storage) { > > + die "It is necessary to pass the storage parameter"; > > + } > > call PVE::Storage::storage_config() here to get both that and the check > that the storage exists.. Do you mean $store_conf->{ids}->{$storage} ? > > > + > > +__PACKAGE__->register_method ({ > > + name => 'importdisk', > > + path => '{vmid}/importdisk', > > + method => 'POST', > > + protected => 1, # for worker upid file > > huh? no - because we need elevated privileges to allocate disks on > storages.. Might be left over, I'll check. > > > + proxyto => 'node', > > + description => "Import an external disk image into a VM. The image format ". > > + "has to be supported by qemu-img.", > > + 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 ". > > how does the second work here? and the whole thing is root-only, so that > qualifier at the end is redundant ;) The description is bad... I guess local:104/toImport.raw is a volid, too? I could just image it to be useful here to allow special volids like --source local:99/somedisk.qcow2 > > > + format => { > > + type => 'string', > > + description => 'Target format.', > > + enum => [ 'raw', 'qcow2', 'vmdk' ], > > + optional => 1, > > + }, > > not directly related to this series, but this enum is copied all over > the place and might be defined in one place as standard option > (qm-new-disk-format ?) Makes sense to me. Only > (qm-new-disk-format ?) raw, qcow2 and vmdk are not only for new disks? Those are allowed formats for every directory storage, so maybe something like dir-disk-format? > > > + digest => get_standard_option('pve-config-digest'), > > + }, > > + }, > > + returns => { type => 'string'}, > > + code => sub { > > + my ($param) = @_; > > + my $vmid = extract_param($param, 'vmid'); > > + my $node = extract_param($param, 'node'); > > + my $original_source = extract_param($param, 'source'); > > not sure why this gets this name, it's just passed once and not used > afterwards.. It was useful previously... Changed it now. > > > + my $digest = extract_param($param, 'digest'); > > + my $device_options = extract_param($param, 'device_options'); > > IMHO this one needs to be parsed - e.g., by adding a fake volume with > the syntax used in importvm at the front and parsing it according to the > disk schema.. > > both to catch bogus stuff early, and to make the handling below more > robust w.r.t. future changes.. OK. The API currently allows any string. Would it be worth to change that then? > > + if ($format_explicit && $format_device_option) { > > + raise_param_exc({format => "Disk format may be specified only once!"}); > > format already specified in device_options? Also good for me. > > > + } > > + } > > + my $format = $format_explicit || $format_device_option; > > since both of those are not needed after this point, this could just be > returned / set by the above if, dropping the extra variables in the > outer scope.. Do you mean with a small function? my $get_format = sub { ... return $explicit || $device_options } my $format = $get_format($device_options, extract_param($param, 'format')); > > > + $check_format_is_supported->($format, $storeid); > > + > > + my $locked = sub { > > + my $conf = PVE::QemuConfig->load_config($vmid); > > + PVE::Tools::assert_if_modified($conf->{digest}, $digest); > > + > > + if ($device && $conf->{$device}) { > > + die "Could not import because device $device is already in ". > > + "use in VM $vmid. Choose a different device!"; > > both of these checks might make sense outside of the worker already to > give immediate feedback in the API response.. I wanted to do this but didn't know what to do with the lock. If I check first and lock later then the config could already have changed? Or check twice? 1. outside the worker, because most times it is OK and gives a nice error and 2. inside the worker, to be really sure? Or lock outside the worker? I'll have to try what is actually possible... > > + } > > > + > > + my $msg = "There must be exactly as many devices specified as there " . > > + " are devices in the diskimage parameter.\n For example for " . > > + "--scsi0 local-lvm:0,discard=on --scsi1 local:0,cache=unsafe " . > > + "there must be --diskimages scsi0=/source/path,scsi1=/other/path"; > > + my $device_count = grep { $use_import->($_) } keys %$param; > > why though? isn't it sufficient to say there must be a matching device for > each diskimages entry and vice-versa? That's what I meant, but your formulation is better. > > > + } > > + > > + my $worker = sub { > > + eval { PVE::QemuConfig->create_and_lock_config($vmid, 0, 'import') }; > > + die "Unable to create config for VM import: $@" if $@; > > should happen outside the worker (so that a VMID clash is detected > early). inside the worker you just lock and load, then check that the > 'import' lock is still there.. > > we could also filter out disk parameters, and call update_vm_api with > the rest here (those should be fast, but potentially in the future could > run into permission issues that would be nice to return early before > doing a massive disk conversion that has to be undone afterwards.. > > > + > > + my @volids_of_imported = (); > > + eval { foreach my $opt (keys %$param) { > > this could then just iterate over the disk parameters I thought about that, too. But I didn't take future permission issues into account. And then having a single loop and no filters seemed like the easier-to-read option to me. I'll change it. > > > + next if ($opt eq 'start'); > > + > > + my $updated_value; > > + if ($use_import->($opt)) { > > + # $opt is bus/device like ide0, scsi5 > > + > > + my $device = PVE::QemuServer::parse_drive($opt, $param->{$opt}); > > $drive? $device is used in this very patch for something else ;) Right. "OK, will fix" to everything that I haven't mentioned. I certainly read it. ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import 2021-02-11 10:32 ` Dominic Jäger @ 2021-02-12 9:03 ` Fabian Grünbichler 2021-02-12 11:13 ` Dominic Jäger 0 siblings, 1 reply; 9+ messages in thread From: Fabian Grünbichler @ 2021-02-12 9:03 UTC (permalink / raw) To: Dominic Jäger, Proxmox VE development discussion On February 11, 2021 11:32 am, Dominic Jäger wrote: > Thank you for looking so carefully! > > On Wed, Feb 10, 2021 at 10:40:56AM +0100, Fabian Grünbichler wrote: >> >> On February 5, 2021 11:04 am, Dominic Jäger wrote: >> > Extend qm importdisk/importovf functionality to the API. >> > >> > @@ -4325,4 +4324,378 @@ __PACKAGE__->register_method({ >> > return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type}); >> > }}); >> > >> > +# Raise exception if $format is not supported by $storageid >> > +my $check_format_is_supported = sub { >> > + my ($format, $storageid) = @_; >> > + >> > + return if !$format; >> > + >> > + my $store_conf = PVE::Storage::config(); >> >> it probably makes sense to pass this in as parameter, all call sites >> probably have it already ;) > > I just noticed that I have it in importdisk, but unused. Does it make a > relevant difference in terms of performance to call ::config() multiple times > instead of passing it as parameter? well, it should be the same and fairly cheap since it comes from a cache after the first request, but if you trigger a cfs_update() in-between you might get two different versions. > >> >> > + } >> > +}; >> > + >> > +# paths are returned as is >> > +# volids are returned as paths >> > +# >> > +# Also checks if $original actually exists >> > +my $convert_to_path = sub { >> > + my ($original) = @_; >> > + my $volid_as_path = eval { # Nonempty iff $original_source is a volid >> > + PVE::Storage::path(PVE::Storage::config(), $original); >> > + }; >> > + my $result = $volid_as_path || $original ; >> > + if (!-e $result) { >> > + die "Could not import because source '$original' does not exist!"; >> > + } >> > + return $result; >> > +}; >> >> what does this do that PVE::Storage::abs_filesystem_path doesn't already >> do (except having 'import' in the error message ;))? > > Haven't thought about that function in the last time... > I'll have a look at it. > >> ah, further down below I see that -b might be missing for raw block >> devices.. maybe it makes sense to allow those in that helper as well? >> optionally? call-sites would need to be checked.. > Would it make sense to not change helper for the moment, certainly not break > other call-sites and refactor later in a separate patch? optionally supporting block devices should not be able to break any existing call sites ;) but yeah, you can also replace your helper here with a fork of abs_filesystem_path that allows -b, that would allow simply switching it out if we want to handle that in pve-storage.. >> >> > + >> > +# vmid ... target VM ID >> > +# source ... absolute path of the source image (volid must be converted before) >> >> that restriction is not actually needed, see below >> >> > +# storage ... target storage for the disk image >> > +# format ... target format for the disk image (optional) >> > +# >> > +# returns ... volid of the allocated disk image (e.g. local-lvm:vm-100-disk-2) >> > +my $import_disk_image = sub { >> > + my ($param) = @_; >> > + my $vmid = $param->{vmid}; >> > + my $requested_format = $param->{format}; >> > + my $storage = $param->{storage}; >> > + my $source = $param->{source}; >> > + >> > + my $vm_conf = PVE::QemuConfig->load_config($vmid); >> >> could be passed in, the call sites already have it.. and it might allow >> to pull in some of the surrounding codes at the call sites that is >> similar (config updating, property string building) into this helper > > It's actually unused. I'll remove it. >> >> > + my $store_conf = PVE::Storage::config(); >> >> could also be passed in here > * see below >> >> > + if (!$source) { >> > + die "It is necessary to pass the source parameter"; >> > + } >> >> just a check for definedness > Hmm but if someone does > $import_disk_image->({vmid => 102, source => "", storage => "local-lvm"}); > then >> >> > + if ($source !~ m!^/!) { >> > + die "source must be an absolute path but is $source"; > will only say "... path but is at /usr/share/..."? no, because that check gets removed as well.. $source could be a volid at this point > >> >> > + if (!$storage) { >> > + die "It is necessary to pass the storage parameter"; >> > + } >> >> call PVE::Storage::storage_config() here to get both that and the check >> that the storage exists.. > Do you mean $store_conf->{ids}->{$storage} ? no, I mean storage_config() ;) it checks whether $storage is defined & non-empty, is an actually defined-in-storage.cfg storage, and returns that storage's config section. > >> >> > + >> > +__PACKAGE__->register_method ({ >> > + name => 'importdisk', >> > + path => '{vmid}/importdisk', >> > + method => 'POST', >> > + protected => 1, # for worker upid file >> >> huh? no - because we need elevated privileges to allocate disks on >> storages.. > Might be left over, I'll check. >> >> > + proxyto => 'node', >> > + description => "Import an external disk image into a VM. The image format ". >> > + "has to be supported by qemu-img.", >> > + 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 ". >> >> how does the second work here? and the whole thing is root-only, so that >> qualifier at the end is redundant ;) > > The description is bad... I guess local:104/toImport.raw is a volid, too? > I could just image it to be useful here to allow special volids like > --source local:99/somedisk.qcow2 it's a volid, but not a valid VM-owned volume that we can do ACL checks on.. so it's important to think about where to use `parse_volid` vs `parse_volname` (the latter gives you all the checks per storage type, and returns type of volume, associated VMID, etc.). for this series/patch, we can accept a proper vdisk volid if the user has access, or any volid/path for root. we can't allow arbitrary volids for unprivileged users, as that would allow reading ANY file on the storage. > >> >> > + format => { >> > + type => 'string', >> > + description => 'Target format.', >> > + enum => [ 'raw', 'qcow2', 'vmdk' ], >> > + optional => 1, >> > + }, >> >> not directly related to this series, but this enum is copied all over >> the place and might be defined in one place as standard option >> (qm-new-disk-format ?) > Makes sense to me. Only >> (qm-new-disk-format ?) > raw, qcow2 and vmdk are not only for new disks? Those are allowed formats for every directory storage, so maybe something like dir-disk-format? as separate parameter they are only used when creating/importing/.. NEW disks. of course, they are also part of the schema as part of property strings. also dir-disk-format would be wrong - just because non-dir-storages only support raw(/subvol) doesn't make the parameter invalid for them ;) >> >> > + digest => get_standard_option('pve-config-digest'), >> > + }, >> > + }, >> > + returns => { type => 'string'}, >> > + code => sub { >> > + my ($param) = @_; >> > + my $vmid = extract_param($param, 'vmid'); >> > + my $node = extract_param($param, 'node'); >> > + my $original_source = extract_param($param, 'source'); >> >> not sure why this gets this name, it's just passed once and not used >> afterwards.. > It was useful previously... Changed it now. >> >> > + my $digest = extract_param($param, 'digest'); >> > + my $device_options = extract_param($param, 'device_options'); >> >> IMHO this one needs to be parsed - e.g., by adding a fake volume with >> the syntax used in importvm at the front and parsing it according to the >> disk schema.. >> >> both to catch bogus stuff early, and to make the handling below more >> robust w.r.t. future changes.. > > OK. The API currently allows any string. Would it be worth to change that then? not sure whether that's trivially possible - the options are not uniform across bus / device types? you could have a 'everything optional superset of all disk device types' schema, that would be less strict than the final check but better than nothing? looking at PVE::QemuServer::Drive it almost exists in $alldrive_fmt, so cloning that and removing the file property could work? >> > + if ($format_explicit && $format_device_option) { >> > + raise_param_exc({format => "Disk format may be specified only once!"}); >> >> format already specified in device_options? > Also good for me. > >> >> > + } >> > + } >> > + my $format = $format_explicit || $format_device_option; >> >> since both of those are not needed after this point, this could just be >> returned / set by the above if, dropping the extra variables in the >> outer scope.. > Do you mean with a small function? > my $get_format = sub { > ... > return $explicit || $device_options > } > my $format = $get_format($device_options, extract_param($param, 'format')); I think a function is overkill for a single use, just my $format; if (...) { // decide and set $format } >> >> > + $check_format_is_supported->($format, $storeid); >> > + >> > + my $locked = sub { >> > + my $conf = PVE::QemuConfig->load_config($vmid); >> > + PVE::Tools::assert_if_modified($conf->{digest}, $digest); >> > + >> > + if ($device && $conf->{$device}) { >> > + die "Could not import because device $device is already in ". >> > + "use in VM $vmid. Choose a different device!"; >> >> both of these checks might make sense outside of the worker already to >> give immediate feedback in the API response.. > I wanted to do this but didn't know what to do with the lock. > If I check first and lock later then the config could already have changed? > Or check twice? > 1. outside the worker, because most times it is OK and gives a nice error and > 2. inside the worker, to be really sure? > > Or lock outside the worker? I'll have to try what is actually possible... check before, compare digest after fork + lock + reload should handle any changes in-between? then you only need to duplicate the digest check. > >> > + } >> >> > + >> > + my $msg = "There must be exactly as many devices specified as there " . >> > + " are devices in the diskimage parameter.\n For example for " . >> > + "--scsi0 local-lvm:0,discard=on --scsi1 local:0,cache=unsafe " . >> > + "there must be --diskimages scsi0=/source/path,scsi1=/other/path"; >> > + my $device_count = grep { $use_import->($_) } keys %$param; >> >> why though? isn't it sufficient to say there must be a matching device for >> each diskimages entry and vice-versa? > That's what I meant, but your formulation is better. it also allows you to give concrete error messages if you do one-to-one mapping: "scsi0 configured for disk import, but no matching import source given" "virtio0 not configured for disk import, but import source '...' given" > >> >> > + } >> > + >> > + my $worker = sub { >> > + eval { PVE::QemuConfig->create_and_lock_config($vmid, 0, 'import') }; >> > + die "Unable to create config for VM import: $@" if $@; >> >> should happen outside the worker (so that a VMID clash is detected >> early). inside the worker you just lock and load, then check that the >> 'import' lock is still there.. >> >> we could also filter out disk parameters, and call update_vm_api with >> the rest here (those should be fast, but potentially in the future could >> run into permission issues that would be nice to return early before >> doing a massive disk conversion that has to be undone afterwards.. >> >> > + >> > + my @volids_of_imported = (); >> > + eval { foreach my $opt (keys %$param) { >> >> this could then just iterate over the disk parameters > I thought about that, too. But I didn't take future permission issues into account. And then having a single loop and no filters seemed like the easier-to-read option to me. > > I'll change it. >> >> > + next if ($opt eq 'start'); >> > + >> > + my $updated_value; >> > + if ($use_import->($opt)) { >> > + # $opt is bus/device like ide0, scsi5 >> > + >> > + my $device = PVE::QemuServer::parse_drive($opt, $param->{$opt}); >> >> $drive? $device is used in this very patch for something else ;) > Right. > > > "OK, will fix" to everything that I haven't mentioned. I certainly read it. ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import 2021-02-12 9:03 ` Fabian Grünbichler @ 2021-02-12 11:13 ` Dominic Jäger 0 siblings, 0 replies; 9+ messages in thread From: Dominic Jäger @ 2021-02-12 11:13 UTC (permalink / raw) To: Fabian Grünbichler; +Cc: Proxmox VE development discussion On Fri, Feb 12, 2021 at 10:03:50AM +0100, Fabian Grünbichler wrote: > >> > + my $store_conf = PVE::Storage::config(); > >> > >> it probably makes sense to pass this in as parameter, all call sites > >> probably have it already ;) > > > > I just noticed that I have it in importdisk, but unused. Does it make a > > relevant difference in terms of performance to call ::config() multiple times > > instead of passing it as parameter? > > well, it should be the same and fairly cheap since it comes from a cache > after the first request, but if you trigger a cfs_update() in-between > you might get two different versions. Makes sense. Fixed it. > >> > +# paths are returned as is > >> > +# volids are returned as paths > >> > +# > >> > +# Also checks if $original actually exists > >> > +my $convert_to_path = sub { > >> > + my ($original) = @_; > >> > + my $volid_as_path = eval { # Nonempty iff $original_source is a volid > >> > + PVE::Storage::path(PVE::Storage::config(), $original); > >> > + }; > >> > + my $result = $volid_as_path || $original ; > >> > + if (!-e $result) { > >> > + die "Could not import because source '$original' does not exist!"; > >> > + } > >> > + return $result; > >> > +}; > >> > >> what does this do that PVE::Storage::abs_filesystem_path doesn't already > >> do (except having 'import' in the error message ;))? > > > > Haven't thought about that function in the last time... > > I'll have a look at it. > > > >> ah, further down below I see that -b might be missing for raw block > >> devices.. maybe it makes sense to allow those in that helper as well? > >> optionally? call-sites would need to be checked.. > > Would it make sense to not change helper for the moment, certainly not break > > other call-sites and refactor later in a separate patch? > > optionally supporting block devices should not be able to break any > existing call sites ;) but yeah, you can also replace your helper here > with a fork of abs_filesystem_path that allows -b, that would allow > simply switching it out if we want to handle that in pve-storage.. > > >> Makes sense, too. I added another parameter to abs_filesystem_path. > > then > >> > >> > + if ($source !~ m!^/!) { > >> > + die "source must be an absolute path but is $source"; > > will only say "... path but is at /usr/share/..."? > > no, because that check gets removed as well.. $source could be a volid > at this point Thank you. It became clear when I changed all the signatures... > > > > >> > >> > + if (!$storage) { > >> > + die "It is necessary to pass the storage parameter"; > >> > + } > >> > >> call PVE::Storage::storage_config() here to get both that and the check > >> that the storage exists.. > > Do you mean $store_conf->{ids}->{$storage} ? > > no, I mean storage_config() ;) > > it checks whether $storage is defined & non-empty, is an actually > defined-in-storage.cfg storage, and returns that storage's config > section. Sorry, now I see it. Reading is hard... > >> > + 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 ". > >> > >> how does the second work here? and the whole thing is root-only, so that > >> qualifier at the end is redundant ;) > > > > The description is bad... I guess local:104/toImport.raw is a volid, too? > > I could just image it to be useful here to allow special volids like > > --source local:99/somedisk.qcow2 > > it's a volid, but not a valid VM-owned volume that we can do ACL checks > on.. so it's important to think about where to use `parse_volid` vs > `parse_volname` (the latter gives you all the checks per storage type, > and returns type of volume, associated VMID, etc.). > > for this series/patch, we can accept a proper vdisk volid if the user > has access, or any volid/path for root. we can't allow arbitrary volids > for unprivileged users, as that would allow reading ANY file on the > storage. OK, will have to give that a closer look. > >> > + format => { > >> > + type => 'string', > >> > + description => 'Target format.', > >> > + enum => [ 'raw', 'qcow2', 'vmdk' ], > >> > + optional => 1, > >> > + }, > >> > >> not directly related to this series, but this enum is copied all over > >> the place and might be defined in one place as standard option > >> (qm-new-disk-format ?) > > Makes sense to me. Only > >> (qm-new-disk-format ?) > > raw, qcow2 and vmdk are not only for new disks? Those are allowed formats for every directory storage, so maybe something like dir-disk-format? > > as separate parameter they are only used when creating/importing/.. NEW > disks. of course, they are also part of the schema as part of property > strings. also dir-disk-format would be wrong - just because > non-dir-storages only support raw(/subvol) doesn't make the parameter > invalid for them ;) Right :D > > >> > >> > + digest => get_standard_option('pve-config-digest'), > >> > + }, > >> > + }, > >> > + returns => { type => 'string'}, > >> > + code => sub { > >> > + my ($param) = @_; > >> > + my $vmid = extract_param($param, 'vmid'); > >> > + my $node = extract_param($param, 'node'); > >> > + my $original_source = extract_param($param, 'source'); > >> > >> not sure why this gets this name, it's just passed once and not used > >> afterwards.. > > It was useful previously... Changed it now. > >> > >> > + my $digest = extract_param($param, 'digest'); > >> > + my $device_options = extract_param($param, 'device_options'); > >> > >> IMHO this one needs to be parsed - e.g., by adding a fake volume with > >> the syntax used in importvm at the front and parsing it according to the > >> disk schema.. > >> > >> both to catch bogus stuff early, and to make the handling below more > >> robust w.r.t. future changes.. > > > > OK. The API currently allows any string. Would it be worth to change that then? > > not sure whether that's trivially possible - the options are not uniform > across bus / device types? you could have a 'everything optional > superset of all disk device types' schema, that would be less strict > than the final check but better than nothing? looking at > PVE::QemuServer::Drive it almost exists in $alldrive_fmt, so cloning > that and removing the file property could work? I'll give it a try. Having some checks sounds better than none at all to me, too. > >> > + } > >> > + my $format = $format_explicit || $format_device_option; > >> > >> since both of those are not needed after this point, this could just be > >> returned / set by the above if, dropping the extra variables in the > >> outer scope.. > > Do you mean with a small function? > > my $get_format = sub { > > ... > > return $explicit || $device_options > > } > > my $format = $get_format($device_options, extract_param($param, 'format')); > > I think a function is overkill for a single use, just > > my $format; > if (...) { > // decide and set $format > } OK. My if-solution so far looks ugly to me, but it avoids those extra variables in outer scope. > >> > + $check_format_is_supported->($format, $storeid); > >> > + > >> > + my $locked = sub { > >> > + my $conf = PVE::QemuConfig->load_config($vmid); > >> > + PVE::Tools::assert_if_modified($conf->{digest}, $digest); > >> > + > >> > + if ($device && $conf->{$device}) { > >> > + die "Could not import because device $device is already in ". > >> > + "use in VM $vmid. Choose a different device!"; > >> > >> both of these checks might make sense outside of the worker already to > >> give immediate feedback in the API response.. > > I wanted to do this but didn't know what to do with the lock. > > If I check first and lock later then the config could already have changed? > > Or check twice? > > 1. outside the worker, because most times it is OK and gives a nice error and > > 2. inside the worker, to be really sure? > > > > Or lock outside the worker? I'll have to try what is actually possible... > > check before, compare digest after fork + lock + reload should handle any > changes in-between? > > then you only need to duplicate the digest check. I like. Done. > > > > >> > + } > >> > >> > + > >> > + my $msg = "There must be exactly as many devices specified as there " . > >> > + " are devices in the diskimage parameter.\n For example for " . > >> > + "--scsi0 local-lvm:0,discard=on --scsi1 local:0,cache=unsafe " . > >> > + "there must be --diskimages scsi0=/source/path,scsi1=/other/path"; > >> > + my $device_count = grep { $use_import->($_) } keys %$param; > >> > >> why though? isn't it sufficient to say there must be a matching device for > >> each diskimages entry and vice-versa? > > That's what I meant, but your formulation is better. > > it also allows you to give concrete error messages if you do one-to-one > mapping: > > "scsi0 configured for disk import, but no matching import source given" > "virtio0 not configured for disk import, but import source '...' given" That might work together well with the filter-out-disk-parameters idea > >> we could also filter out disk parameters, and call update_vm_api with > >> the rest here (those should be fast, but potentially in the future could > >> run into permission issues that would be nice to return early before > >> doing a massive disk conversion that has to be undone afterwards.. > >> > >> > + > >> > + my @volids_of_imported = (); > >> > + eval { foreach my $opt (keys %$param) { > >> > >> this could then just iterate over the disk parameters ^ permalink raw reply [flat|nested] 9+ messages in thread
end of thread, other threads:[~2021-03-08 12:39 UTC | newest] Thread overview: 9+ messages (download: mbox.gz / follow: Atom feed) -- links below jump to the message on this page -- 2021-02-05 10:04 [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Dominic Jäger 2021-02-05 10:04 ` [pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM Dominic Jäger 2021-02-10 9:49 ` Fabian Grünbichler 2021-03-08 11:38 ` Dominic Jäger 2021-03-08 12:39 ` Fabian Grünbichler 2021-02-10 9:40 ` [pve-devel] [PATCH v4 qemu-server] Add API for disk & VM import Fabian Grünbichler 2021-02-11 10:32 ` Dominic Jäger 2021-02-12 9:03 ` Fabian Grünbichler 2021-02-12 11:13 ` Dominic Jäger
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox