[pve-devel] [PATCH FOLLOW-UP v6 container 1/3] migration: add remote migration

Fabian Grünbichler f.gruenbichler at proxmox.com
Mon Oct 3 15:22:37 CEST 2022


same as in qemu-server, the following should be squashed into this 
patch/commit:

----8<----
diff --git a/src/PVE/API2/LXC.pm b/src/PVE/API2/LXC.pm
index 4e21be4..3573b59 100644
--- a/src/PVE/API2/LXC.pm
+++ b/src/PVE/API2/LXC.pm
@@ -2870,7 +2870,7 @@ __PACKAGE__->register_method({
 		    print "received command '$cmd'\n";
 		    eval {
 			if ($cmd_desc->{$cmd}) {
-			    PVE::JSONSchema::validate($cmd_desc->{$cmd}, $parsed);
+			    PVE::JSONSchema::validate($parsed, $cmd_desc->{$cmd});
 			} else {
 			    $parsed = {};
 			}
---->8----

On September 28, 2022 2:50 pm, Fabian Grünbichler wrote:
> modelled after the VM migration, but folded into a single commit since
> the actual migration changes are a lot smaller here.
> 
> Signed-off-by: Fabian Grünbichler <f.gruenbichler at proxmox.com>
> ---
> 
> Notes:
>     v6:
>     - check for Sys.Incoming in mtunnel API endpoint
>     - mark as experimental
>     - test_mp fix for non-snapshot calls
>     
>     new in v5 - PoC to ensure helpers and abstractions are re-usable
>     
>     requires bumped pve-storage to avoid tainted issue
> 
>  src/PVE/API2/LXC.pm    | 635 +++++++++++++++++++++++++++++++++++++++++
>  src/PVE/LXC/Migrate.pm | 245 +++++++++++++---
>  2 files changed, 838 insertions(+), 42 deletions(-)
> 
> diff --git a/src/PVE/API2/LXC.pm b/src/PVE/API2/LXC.pm
> index 589f96f..4e21be4 100644
> --- a/src/PVE/API2/LXC.pm
> +++ b/src/PVE/API2/LXC.pm
> @@ -3,6 +3,8 @@ package PVE::API2::LXC;
>  use strict;
>  use warnings;
>  
> +use Socket qw(SOCK_STREAM);
> +
>  use PVE::SafeSyslog;
>  use PVE::Tools qw(extract_param run_command);
>  use PVE::Exception qw(raise raise_param_exc raise_perm_exc);
> @@ -1089,6 +1091,174 @@ __PACKAGE__->register_method ({
>      }});
>  
>  
> +__PACKAGE__->register_method({
> +    name => 'remote_migrate_vm',
> +    path => '{vmid}/remote_migrate',
> +    method => 'POST',
> +    protected => 1,
> +    proxyto => 'node',
> +    description => "Migrate the container to another cluster. Creates a new migration task. EXPERIMENTAL feature!",
> +    permissions => {
> +	check => ['perm', '/vms/{vmid}', [ 'VM.Migrate' ]],
> +    },
> +    parameters => {
> +    	additionalProperties => 0,
> +	properties => {
> +	    node => get_standard_option('pve-node'),
> +	    vmid => get_standard_option('pve-vmid', { completion => \&PVE::LXC::complete_ctid }),
> +	    'target-vmid' => get_standard_option('pve-vmid', { optional => 1 }),
> +	    'target-endpoint' => get_standard_option('proxmox-remote', {
> +		description => "Remote target endpoint",
> +	    }),
> +	    online => {
> +		type => 'boolean',
> +		description => "Use online/live migration.",
> +		optional => 1,
> +	    },
> +	    restart => {
> +		type => 'boolean',
> +		description => "Use restart migration",
> +		optional => 1,
> +	    },
> +	    timeout => {
> +		type => 'integer',
> +		description => "Timeout in seconds for shutdown for restart migration",
> +		optional => 1,
> +		default => 180,
> +	    },
> +	    delete => {
> +		type => 'boolean',
> +		description => "Delete the original CT and related data after successful migration. By default the original CT is kept on the source cluster in a stopped state.",
> +		optional => 1,
> +		default => 0,
> +	    },
> +	    'target-storage' => get_standard_option('pve-targetstorage', {
> +		optional => 0,
> +	    }),
> +	    'target-bridge' => {
> +		type => 'string',
> +		description => "Mapping from source to target bridges. Providing only a single bridge ID maps all source bridges to that bridge. Providing the special value '1' will map each source bridge to itself.",
> +		format => 'bridge-pair-list',
> +	    },
> +	    bwlimit => {
> +		description => "Override I/O bandwidth limit (in KiB/s).",
> +		optional => 1,
> +		type => 'number',
> +		minimum => '0',
> +		default => 'migrate limit from datacenter or storage config',
> +	    },
> +	},
> +    },
> +    returns => {
> +	type => 'string',
> +	description => "the task ID.",
> +    },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PVE::RPCEnvironment::get();
> +	my $authuser = $rpcenv->get_user();
> +
> +	my $source_vmid = extract_param($param, 'vmid');
> +	my $target_endpoint = extract_param($param, 'target-endpoint');
> +	my $target_vmid = extract_param($param, 'target-vmid') // $source_vmid;
> +
> +	my $delete = extract_param($param, 'delete') // 0;
> +
> +	PVE::Cluster::check_cfs_quorum();
> +
> +	# test if CT exists
> +	my $conf = PVE::LXC::Config->load_config($source_vmid);
> +	PVE::LXC::Config->check_lock($conf);
> +
> +	# try to detect errors early
> +	if (PVE::LXC::check_running($source_vmid)) {
> +	    die "can't migrate running container without --online or --restart\n"
> +		if !$param->{online} && !$param->{restart};
> +	}
> +
> +	raise_param_exc({ vmid => "cannot migrate HA-managed CT to remote cluster" })
> +	    if PVE::HA::Config::vm_is_ha_managed($source_vmid);
> +
> +	my $remote = PVE::JSONSchema::parse_property_string('proxmox-remote', $target_endpoint);
> +
> +	# TODO: move this as helper somewhere appropriate?
> +	my $conn_args = {
> +	    protocol => 'https',
> +	    host => $remote->{host},
> +	    port => $remote->{port} // 8006,
> +	    apitoken => $remote->{apitoken},
> +	};
> +
> +	my $fp;
> +	if ($fp = $remote->{fingerprint}) {
> +	    $conn_args->{cached_fingerprints} = { uc($fp) => 1 };
> +	}
> +
> +	print "Establishing API connection with remote at '$remote->{host}'\n";
> +
> +	my $api_client = PVE::APIClient::LWP->new(%$conn_args);
> +
> +	if (!defined($fp)) {
> +	    my $cert_info = $api_client->get("/nodes/localhost/certificates/info");
> +	    foreach my $cert (@$cert_info) {
> +		my $filename = $cert->{filename};
> +		next if $filename ne 'pveproxy-ssl.pem' && $filename ne 'pve-ssl.pem';
> +		$fp = $cert->{fingerprint} if !$fp || $filename eq 'pveproxy-ssl.pem';
> +	    }
> +	    $conn_args->{cached_fingerprints} = { uc($fp) => 1 }
> +		if defined($fp);
> +	}
> +
> +	my $storecfg = PVE::Storage::config();
> +	my $target_storage = extract_param($param, 'target-storage');
> +	my $storagemap = eval { PVE::JSONSchema::parse_idmap($target_storage, 'pve-storage-id') };
> +	raise_param_exc({ 'target-storage' => "failed to parse storage map: $@" })
> +	    if $@;
> +
> +	my $target_bridge = extract_param($param, 'target-bridge');
> +	my $bridgemap = eval { PVE::JSONSchema::parse_idmap($target_bridge, 'pve-bridge-id') };
> +	raise_param_exc({ 'target-bridge' => "failed to parse bridge map: $@" })
> +	    if $@;
> +
> +	die "remote migration requires explicit storage mapping!\n"
> +	    if $storagemap->{identity};
> +
> +	$param->{storagemap} = $storagemap;
> +	$param->{bridgemap} = $bridgemap;
> +	$param->{remote} = {
> +	    conn => $conn_args, # re-use fingerprint for tunnel
> +	    client => $api_client,
> +	    vmid => $target_vmid,
> +	};
> +	$param->{migration_type} = 'websocket';
> +	$param->{delete} = $delete if $delete;
> +
> +	my $cluster_status = $api_client->get("/cluster/status");
> +	my $target_node;
> +	foreach my $entry (@$cluster_status) {
> +	    next if $entry->{type} ne 'node';
> +	    if ($entry->{local}) {
> +		$target_node = $entry->{name};
> +		last;
> +	    }
> +	}
> +
> +	die "couldn't determine endpoint's node name\n"
> +	    if !defined($target_node);
> +
> +	my $realcmd = sub {
> +	    PVE::LXC::Migrate->migrate($target_node, $remote->{host}, $source_vmid, $param);
> +	};
> +
> +	my $worker = sub {
> +	    return PVE::GuestHelpers::guest_migration_lock($source_vmid, 10, $realcmd);
> +	};
> +
> +	return $rpcenv->fork_worker('vzmigrate', $source_vmid, $authuser, $worker);
> +    }});
> +
> +
>  __PACKAGE__->register_method({
>      name => 'migrate_vm',
>      path => '{vmid}/migrate',
> @@ -2318,4 +2488,469 @@ __PACKAGE__->register_method({
>  	return PVE::GuestHelpers::config_with_pending_array($conf, $pending_delete_hash);
>      }});
>  
> +__PACKAGE__->register_method({
> +    name => 'mtunnel',
> +    path => '{vmid}/mtunnel',
> +    method => 'POST',
> +    protected => 1,
> +    description => 'Migration tunnel endpoint - only for internal use by CT migration.',
> +    permissions => {
> +	check =>
> +	[ 'and',
> +	  ['perm', '/vms/{vmid}', [ 'VM.Allocate' ]],
> +	  ['perm', '/', [ 'Sys.Incoming' ]],
> +	],
> +	description => "You need 'VM.Allocate' permissions on '/vms/{vmid}' and Sys.Incoming" .
> +	               " on '/'. Further permission checks happen during the actual migration.",
> +    },
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    node => get_standard_option('pve-node'),
> +	    vmid => get_standard_option('pve-vmid'),
> +	    storages => {
> +		type => 'string',
> +		format => 'pve-storage-id-list',
> +		optional => 1,
> +		description => 'List of storages to check permission and availability. Will be checked again for all actually used storages during migration.',
> +	    },
> +	    bridges => {
> +		type => 'string',
> +		format => 'pve-bridge-id-list',
> +		optional => 1,
> +		description => 'List of network bridges to check availability. Will be checked again for actually used bridges during migration.',
> +	    },
> +	},
> +    },
> +    returns => {
> +	additionalProperties => 0,
> +	properties => {
> +	    upid => { type => 'string' },
> +	    ticket => { type => 'string' },
> +	    socket => { type => 'string' },
> +	},
> +    },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PVE::RPCEnvironment::get();
> +	my $authuser = $rpcenv->get_user();
> +
> +	my $node = extract_param($param, 'node');
> +	my $vmid = extract_param($param, 'vmid');
> +
> +	my $storages = extract_param($param, 'storages');
> +	my $bridges = extract_param($param, 'bridges');
> +
> +	my $nodename = PVE::INotify::nodename();
> +
> +	raise_param_exc({ node => "node needs to be 'localhost' or local hostname '$nodename'" })
> +	    if $node ne 'localhost' && $node ne $nodename;
> +
> +	$node = $nodename;
> +
> +	my $storecfg = PVE::Storage::config();
> +	foreach my $storeid (PVE::Tools::split_list($storages)) {
> +	    $check_storage_access_migrate->($rpcenv, $authuser, $storecfg, $storeid, $node);
> +	}
> +
> +	foreach my $bridge (PVE::Tools::split_list($bridges)) {
> +	    PVE::Network::read_bridge_mtu($bridge);
> +	}
> +
> +	PVE::Cluster::check_cfs_quorum();
> +
> +	my $socket_addr = "/run/pve/ct-$vmid.mtunnel";
> +
> +	my $lock = 'create';
> +	eval { PVE::LXC::Config->create_and_lock_config($vmid, 0, $lock); };
> +
> +	raise_param_exc({ vmid => "unable to create empty CT config - $@"})
> +	    if $@;
> +
> +	my $realcmd = sub {
> +	    my $state = {
> +		storecfg => PVE::Storage::config(),
> +		lock => $lock,
> +		vmid => $vmid,
> +	    };
> +
> +	    my $run_locked = sub {
> +		my ($code, $params) = @_;
> +		return PVE::LXC::Config->lock_config($state->{vmid}, sub {
> +		    my $conf = PVE::LXC::Config->load_config($state->{vmid});
> +
> +		    $state->{conf} = $conf;
> +
> +		    die "Encountered wrong lock - aborting mtunnel command handling.\n"
> +			if $state->{lock} && !PVE::LXC::Config->has_lock($conf, $state->{lock});
> +
> +		    return $code->($params);
> +		});
> +	    };
> +
> +	    my $cmd_desc = {
> +		config => {
> +		    conf => {
> +			type => 'string',
> +			description => 'Full CT config, adapted for target cluster/node',
> +		    },
> +		    'firewall-config' => {
> +			type => 'string',
> +			description => 'CT firewall config',
> +			optional => 1,
> +		    },
> +		},
> +		ticket => {
> +		    path => {
> +			type => 'string',
> +			description => 'socket path for which the ticket should be valid. must be known to current mtunnel instance.',
> +		    },
> +		},
> +		quit => {
> +		    cleanup => {
> +			type => 'boolean',
> +			description => 'remove CT config and volumes, aborting migration',
> +			default => 0,
> +		    },
> +		},
> +		'disk-import' => $PVE::StorageTunnel::cmd_schema->{'disk-import'},
> +		'query-disk-import' => $PVE::StorageTunnel::cmd_schema->{'query-disk-import'},
> +		bwlimit => $PVE::StorageTunnel::cmd_schema->{bwlimit},
> +	    };
> +
> +	    my $cmd_handlers = {
> +		'version' => sub {
> +		    # compared against other end's version
> +		    # bump/reset for breaking changes
> +		    # bump/bump for opt-in changes
> +		    return {
> +			api => $PVE::LXC::Migrate::WS_TUNNEL_VERSION,
> +			age => 0,
> +		    };
> +		},
> +		'config' => sub {
> +		    my ($params) = @_;
> +
> +		    # parse and write out VM FW config if given
> +		    if (my $fw_conf = $params->{'firewall-config'}) {
> +			my ($path, $fh) = PVE::Tools::tempfile_contents($fw_conf, 700);
> +
> +			my $empty_conf = {
> +			    rules => [],
> +			    options => {},
> +			    aliases => {},
> +			    ipset => {} ,
> +			    ipset_comments => {},
> +			};
> +			my $cluster_fw_conf = PVE::Firewall::load_clusterfw_conf();
> +
> +			# TODO: add flag for strict parsing?
> +			# TODO: add import sub that does all this given raw content?
> +			my $vmfw_conf = PVE::Firewall::generic_fw_config_parser($path, $cluster_fw_conf, $empty_conf, 'vm');
> +			$vmfw_conf->{vmid} = $state->{vmid};
> +			PVE::Firewall::save_vmfw_conf($state->{vmid}, $vmfw_conf);
> +
> +			$state->{cleanup}->{fw} = 1;
> +		    }
> +
> +		    my $conf_fn = "incoming/lxc/$state->{vmid}.conf";
> +		    my $new_conf = PVE::LXC::Config::parse_pct_config($conf_fn, $params->{conf}, 1);
> +		    delete $new_conf->{lock};
> +		    delete $new_conf->{digest};
> +
> +		    my $unprivileged = delete $new_conf->{unprivileged};
> +		    my $arch = delete $new_conf->{arch};
> +
> +		    # TODO handle properly?
> +		    delete $new_conf->{snapshots};
> +		    delete $new_conf->{parent};
> +		    delete $new_conf->{pending};
> +		    delete $new_conf->{lxc};
> +
> +		    PVE::LXC::Config->remove_lock($state->{vmid}, 'create');
> +
> +		    eval {
> +			my $conf = {
> +			    unprivileged => $unprivileged,
> +			    arch => $arch,
> +			};
> +			PVE::LXC::check_ct_modify_config_perm(
> +			    $rpcenv,
> +			    $authuser,
> +			    $state->{vmid},
> +			    undef,
> +			    $conf,
> +			    $new_conf,
> +			    undef,
> +			    $unprivileged,
> +			);
> +			my $errors = PVE::LXC::Config->update_pct_config(
> +			    $state->{vmid},
> +			    $conf,
> +			    0,
> +			    $new_conf,
> +			    [],
> +			    [],
> +			);
> +			raise_param_exc($errors) if scalar(keys %$errors);
> +			PVE::LXC::Config->write_config($state->{vmid}, $conf);
> +			PVE::LXC::update_lxc_config($vmid, $conf);
> +		    };
> +		    if (my $err = $@) {
> +			# revert to locked previous config
> +			my $conf = PVE::LXC::Config->load_config($state->{vmid});
> +			$conf->{lock} = 'create';
> +			PVE::LXC::Config->write_config($state->{vmid}, $conf);
> +
> +			die $err;
> +		    }
> +
> +		    my $conf = PVE::LXC::Config->load_config($state->{vmid});
> +		    $conf->{lock} = 'migrate';
> +		    PVE::LXC::Config->write_config($state->{vmid}, $conf);
> +
> +		    $state->{lock} = 'migrate';
> +
> +		    return;
> +		},
> +		'bwlimit' => sub {
> +		    my ($params) = @_;
> +		    return PVE::StorageTunnel::handle_bwlimit($params);
> +		},
> +		'disk-import' => sub {
> +		    my ($params) = @_;
> +
> +		    $check_storage_access_migrate->(
> +			$rpcenv,
> +			$authuser,
> +			$state->{storecfg},
> +			$params->{storage},
> +			$node
> +		    );
> +
> +		    $params->{unix} = "/run/pve/ct-$state->{vmid}.storage";
> +
> +		    return PVE::StorageTunnel::handle_disk_import($state, $params);
> +		},
> +		'query-disk-import' => sub {
> +		    my ($params) = @_;
> +
> +		    return PVE::StorageTunnel::handle_query_disk_import($state, $params);
> +		},
> +		'unlock' => sub {
> +		    PVE::LXC::Config->remove_lock($state->{vmid}, $state->{lock});
> +		    delete $state->{lock};
> +		    return;
> +		},
> +		'start' => sub {
> +		    PVE::LXC::vm_start(
> +			$state->{vmid},
> +			$state->{conf},
> +			0
> +		    );
> +
> +		    return;
> +		},
> +		'stop' => sub {
> +		    PVE::LXC::vm_stop($state->{vmid}, 1, 10, 1);
> +		    return;
> +		},
> +		'ticket' => sub {
> +		    my ($params) = @_;
> +
> +		    my $path = $params->{path};
> +
> +		    die "Not allowed to generate ticket for unknown socket '$path'\n"
> +			if !defined($state->{sockets}->{$path});
> +
> +		    return { ticket => PVE::AccessControl::assemble_tunnel_ticket($authuser, "/socket/$path") };
> +		},
> +		'quit' => sub {
> +		    my ($params) = @_;
> +
> +		    if ($params->{cleanup}) {
> +			if ($state->{cleanup}->{fw}) {
> +			    PVE::Firewall::remove_vmfw_conf($state->{vmid});
> +			}
> +
> +			for my $volid (keys $state->{cleanup}->{volumes}->%*) {
> +			    print "freeing volume '$volid' as part of cleanup\n";
> +			    eval { PVE::Storage::vdisk_free($state->{storecfg}, $volid) };
> +			    warn $@ if $@;
> +			}
> +
> +			PVE::LXC::destroy_lxc_container(
> +			    $state->{storecfg},
> +			    $state->{vmid},
> +			    $state->{conf},
> +			    undef,
> +			    0,
> +			);
> +		    }
> +
> +		    print "switching to exit-mode, waiting for client to disconnect\n";
> +		    $state->{exit} = 1;
> +		    return;
> +		},
> +	    };
> +
> +	    $run_locked->(sub {
> +		my $socket_addr = "/run/pve/ct-$state->{vmid}.mtunnel";
> +		unlink $socket_addr;
> +
> +		$state->{socket} = IO::Socket::UNIX->new(
> +	            Type => SOCK_STREAM(),
> +		    Local => $socket_addr,
> +		    Listen => 1,
> +		);
> +
> +		$state->{socket_uid} = getpwnam('www-data')
> +		    or die "Failed to resolve user 'www-data' to numeric UID\n";
> +		chown $state->{socket_uid}, -1, $socket_addr;
> +	    });
> +
> +	    print "mtunnel started\n";
> +
> +	    my $conn = eval { PVE::Tools::run_with_timeout(300, sub { $state->{socket}->accept() }) };
> +	    if ($@) {
> +		warn "Failed to accept tunnel connection - $@\n";
> +
> +		warn "Removing tunnel socket..\n";
> +		unlink $state->{socket};
> +
> +		warn "Removing temporary VM config..\n";
> +		$run_locked->(sub {
> +		    PVE::LXC::destroy_config($state->{vmid});
> +		});
> +
> +		die "Exiting mtunnel\n";
> +	    }
> +
> +	    $state->{conn} = $conn;
> +
> +	    my $reply_err = sub {
> +		my ($msg) = @_;
> +
> +		my $reply = JSON::encode_json({
> +		    success => JSON::false,
> +		    msg => $msg,
> +		});
> +		$conn->print("$reply\n");
> +		$conn->flush();
> +	    };
> +
> +	    my $reply_ok = sub {
> +		my ($res) = @_;
> +
> +		$res->{success} = JSON::true;
> +		my $reply = JSON::encode_json($res);
> +		$conn->print("$reply\n");
> +		$conn->flush();
> +	    };
> +
> +	    while (my $line = <$conn>) {
> +		chomp $line;
> +
> +		# untaint, we validate below if needed
> +		($line) = $line =~ /^(.*)$/;
> +		my $parsed = eval { JSON::decode_json($line) };
> +		if ($@) {
> +		    $reply_err->("failed to parse command - $@");
> +		    next;
> +		}
> +
> +		my $cmd = delete $parsed->{cmd};
> +		if (!defined($cmd)) {
> +		    $reply_err->("'cmd' missing");
> +		} elsif ($state->{exit}) {
> +		    $reply_err->("tunnel is in exit-mode, processing '$cmd' cmd not possible");
> +		    next;
> +		} elsif (my $handler = $cmd_handlers->{$cmd}) {
> +		    print "received command '$cmd'\n";
> +		    eval {
> +			if ($cmd_desc->{$cmd}) {
> +			    PVE::JSONSchema::validate($cmd_desc->{$cmd}, $parsed);
> +			} else {
> +			    $parsed = {};
> +			}
> +			my $res = $run_locked->($handler, $parsed);
> +			$reply_ok->($res);
> +		    };
> +		    $reply_err->("failed to handle '$cmd' command - $@")
> +			if $@;
> +		} else {
> +		    $reply_err->("unknown command '$cmd' given");
> +		}
> +	    }
> +
> +	    if ($state->{exit}) {
> +		print "mtunnel exited\n";
> +	    } else {
> +		die "mtunnel exited unexpectedly\n";
> +	    }
> +	};
> +
> +	my $ticket = PVE::AccessControl::assemble_tunnel_ticket($authuser, "/socket/$socket_addr");
> +	my $upid = $rpcenv->fork_worker('vzmtunnel', $vmid, $authuser, $realcmd);
> +
> +	return {
> +	    ticket => $ticket,
> +	    upid => $upid,
> +	    socket => $socket_addr,
> +	};
> +    }});
> +
> +__PACKAGE__->register_method({
> +    name => 'mtunnelwebsocket',
> +    path => '{vmid}/mtunnelwebsocket',
> +    method => 'GET',
> +    permissions => {
> +	description => "You need to pass a ticket valid for the selected socket. Tickets can be created via the mtunnel API call, which will check permissions accordingly.",
> +        user => 'all', # check inside
> +    },
> +    description => 'Migration tunnel endpoint for websocket upgrade - only for internal use by VM migration.',
> +    parameters => {
> +	additionalProperties => 0,
> +	properties => {
> +	    node => get_standard_option('pve-node'),
> +	    vmid => get_standard_option('pve-vmid'),
> +	    socket => {
> +		type => "string",
> +		description => "unix socket to forward to",
> +	    },
> +	    ticket => {
> +		type => "string",
> +		description => "ticket return by initial 'mtunnel' API call, or retrieved via 'ticket' tunnel command",
> +	    },
> +	},
> +    },
> +    returns => {
> +	type => "object",
> +	properties => {
> +	    port => { type => 'string', optional => 1 },
> +	    socket => { type => 'string', optional => 1 },
> +	},
> +    },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $rpcenv = PVE::RPCEnvironment::get();
> +	my $authuser = $rpcenv->get_user();
> +
> +	my $nodename = PVE::INotify::nodename();
> +	my $node = extract_param($param, 'node');
> +
> +	raise_param_exc({ node => "node needs to be 'localhost' or local hostname '$nodename'" })
> +	    if $node ne 'localhost' && $node ne $nodename;
> +
> +	my $vmid = $param->{vmid};
> +	# check VM exists
> +	PVE::LXC::Config->load_config($vmid);
> +
> +	my $socket = $param->{socket};
> +	PVE::AccessControl::verify_tunnel_ticket($param->{ticket}, $authuser, "/socket/$socket");
> +
> +	return { socket => $socket };
> +    }});
>  1;
> diff --git a/src/PVE/LXC/Migrate.pm b/src/PVE/LXC/Migrate.pm
> index 2ef1cce..a0ab65e 100644
> --- a/src/PVE/LXC/Migrate.pm
> +++ b/src/PVE/LXC/Migrate.pm
> @@ -17,6 +17,9 @@ use PVE::Replication;
>  
>  use base qw(PVE::AbstractMigrate);
>  
> +# compared against remote end's minimum version
> +our $WS_TUNNEL_VERSION = 2;
> +
>  sub lock_vm {
>      my ($self, $vmid, $code, @param) = @_;
>  
> @@ -28,6 +31,7 @@ sub prepare {
>  
>      my $online = $self->{opts}->{online};
>      my $restart= $self->{opts}->{restart};
> +    my $remote = $self->{opts}->{remote};
>  
>      $self->{storecfg} = PVE::Storage::config();
>  
> @@ -44,6 +48,7 @@ sub prepare {
>      }
>      $self->{was_running} = $running;
>  
> +    my $storages = {};
>      PVE::LXC::Config->foreach_volume_full($conf, { include_unused => 1 }, sub {
>  	my ($ms, $mountpoint) = @_;
>  
> @@ -70,7 +75,7 @@ sub prepare {
>  	die "content type 'rootdir' is not available on storage '$storage'\n"
>  	    if !$scfg->{content}->{rootdir};
>  
> -	if ($scfg->{shared}) {
> +	if ($scfg->{shared} && !$remote) {
>  	    # PVE::Storage::activate_storage checks this for non-shared storages
>  	    my $plugin = PVE::Storage::Plugin->lookup($scfg->{type});
>  	    warn "Used shared storage '$storage' is not online on source node!\n"
> @@ -83,18 +88,63 @@ sub prepare {
>  	    $targetsid = PVE::JSONSchema::map_id($self->{opts}->{storagemap}, $storage);
>  	}
>  
> -	my $target_scfg = PVE::Storage::storage_check_enabled($self->{storecfg}, $targetsid, $self->{node});
> +	if (!$remote) {
> +	    my $target_scfg = PVE::Storage::storage_check_enabled($self->{storecfg}, $targetsid, $self->{node});
> +
> +	    die "$volid: content type 'rootdir' is not available on storage '$targetsid'\n"
> +		if !$target_scfg->{content}->{rootdir};
> +	}
>  
> -	die "$volid: content type 'rootdir' is not available on storage '$targetsid'\n"
> -	    if !$target_scfg->{content}->{rootdir};
> +	$storages->{$targetsid} = 1;
>      });
>  
>      # todo: test if VM uses local resources
>  
> -    # test ssh connection
> -    my $cmd = [ @{$self->{rem_ssh}}, '/bin/true' ];
> -    eval { $self->cmd_quiet($cmd); };
> -    die "Can't connect to destination address using public key\n" if $@;
> +    if ($remote) {
> +	# test & establish websocket connection
> +	my $bridges = map_bridges($conf, $self->{opts}->{bridgemap}, 1);
> +
> +	my $remote = $self->{opts}->{remote};
> +	my $conn = $remote->{conn};
> +
> +	my $log = sub {
> +	    my ($level, $msg) = @_;
> +	    $self->log($level, $msg);
> +	};
> +
> +	my $websocket_url = "https://$conn->{host}:$conn->{port}/api2/json/nodes/$self->{node}/lxc/$remote->{vmid}/mtunnelwebsocket";
> +	my $url = "/nodes/$self->{node}/lxc/$remote->{vmid}/mtunnel";
> +
> +	my $tunnel_params = {
> +	    url => $websocket_url,
> +	};
> +
> +	my $storage_list = join(',', keys %$storages);
> +	my $bridge_list = join(',', keys %$bridges);
> +
> +	my $req_params = {
> +	    storages => $storage_list,
> +	    bridges => $bridge_list,
> +	};
> +
> +	my $tunnel = PVE::Tunnel::fork_websocket_tunnel($conn, $url, $req_params, $tunnel_params, $log);
> +	my $min_version = $tunnel->{version} - $tunnel->{age};
> +	$self->log('info', "local WS tunnel version: $WS_TUNNEL_VERSION");
> +	$self->log('info', "remote WS tunnel version: $tunnel->{version}");
> +	$self->log('info', "minimum required WS tunnel version: $min_version");
> +	die "Remote tunnel endpoint not compatible, upgrade required\n"
> +	    if $WS_TUNNEL_VERSION < $min_version;
> +	 die "Remote tunnel endpoint too old, upgrade required\n"
> +	    if $WS_TUNNEL_VERSION > $tunnel->{version};
> +
> +	$self->log('info', "websocket tunnel started\n");
> +	$self->{tunnel} = $tunnel;
> +    } else {
> +	# test ssh connection
> +	my $cmd = [ @{$self->{rem_ssh}}, '/bin/true' ];
> +	eval { $self->cmd_quiet($cmd); };
> +	die "Can't connect to destination address using public key\n" if $@;
> +    }
>  
>      # in restart mode, we shutdown the container before migrating
>      if ($restart && $running) {
> @@ -113,6 +163,8 @@ sub prepare {
>  sub phase1 {
>      my ($self, $vmid) = @_;
>  
> +    my $remote = $self->{opts}->{remote};
> +
>      $self->log('info', "starting migration of CT $self->{vmid} to node '$self->{node}' ($self->{nodeip})");
>  
>      my $conf = $self->{vmconf};
> @@ -147,7 +199,7 @@ sub phase1 {
>  
>  	my $targetsid = $sid;
>  
> -	if ($scfg->{shared}) {
> +	if ($scfg->{shared} && !$remote) {
>  	    $self->log('info', "volume '$volid' is on shared storage '$sid'")
>  		if !$snapname;
>  	    return;
> @@ -155,7 +207,8 @@ sub phase1 {
>  	    $targetsid = PVE::JSONSchema::map_id($self->{opts}->{storagemap}, $sid);
>  	}
>  
> -	PVE::Storage::storage_check_enabled($self->{storecfg}, $targetsid, $self->{node});
> +	PVE::Storage::storage_check_enabled($self->{storecfg}, $targetsid, $self->{node})
> +	    if !$remote;
>  
>  	my $bwlimit = $self->get_bwlimit($sid, $targetsid);
>  
> @@ -192,6 +245,9 @@ sub phase1 {
>  
>  	eval {
>  	    &$test_volid($volid, $snapname);
> +
> +	    die "remote migration with snapshots not supported yet\n"
> +		if $remote && $snapname;
>  	};
>  
>  	&$log_error($@, $volid) if $@;
> @@ -201,7 +257,7 @@ sub phase1 {
>      my @sids = PVE::Storage::storage_ids($self->{storecfg});
>      foreach my $storeid (@sids) {
>  	my $scfg = PVE::Storage::storage_config($self->{storecfg}, $storeid);
> -	next if $scfg->{shared};
> +	next if $scfg->{shared} && !$remote;
>  	next if !PVE::Storage::storage_check_enabled($self->{storecfg}, $storeid, undef, 1);
>  
>  	# get list from PVE::Storage (for unreferenced volumes)
> @@ -211,10 +267,12 @@ sub phase1 {
>  
>  	# check if storage is available on target node
>  	my $targetsid = PVE::JSONSchema::map_id($self->{opts}->{storagemap}, $storeid);
> -	my $target_scfg = PVE::Storage::storage_check_enabled($self->{storecfg}, $targetsid, $self->{node});
> +	if (!$remote) {
> +	    my $target_scfg = PVE::Storage::storage_check_enabled($self->{storecfg}, $targetsid, $self->{node});
>  
> -	die "content type 'rootdir' is not available on storage '$targetsid'\n"
> -	    if !$target_scfg->{content}->{rootdir};
> +	    die "content type 'rootdir' is not available on storage '$targetsid'\n"
> +		if !$target_scfg->{content}->{rootdir};
> +	}
>  
>  	PVE::Storage::foreach_volid($dl, sub {
>  	    my ($volid, $sid, $volname) = @_;
> @@ -240,12 +298,21 @@ sub phase1 {
>  	    my ($sid, $volname) = PVE::Storage::parse_volume_id($volid);
>  	    my $scfg =  PVE::Storage::storage_config($self->{storecfg}, $sid);
>  
> -	    my $migratable = ($scfg->{type} eq 'dir') || ($scfg->{type} eq 'zfspool')
> -		|| ($scfg->{type} eq 'lvmthin') || ($scfg->{type} eq 'lvm')
> -		|| ($scfg->{type} eq 'btrfs');
> +	    # TODO move to storage plugin layer?
> +	    my $migratable_storages = [
> +		'dir',
> +		'zfspool',
> +		'lvmthin',
> +		'lvm',
> +		'btrfs',
> +	    ];
> +	    if ($remote) {
> +		push @$migratable_storages, 'cifs';
> +		push @$migratable_storages, 'nfs';
> +	    }
>  
>  	    die "storage type '$scfg->{type}' not supported\n"
> -		if !$migratable;
> +		if !grep { $_ eq $scfg->{type} } @$migratable_storages;
>  
>  	    # image is a linked clone on local storage, se we can't migrate.
>  	    if (my $basename = (PVE::Storage::parse_volname($self->{storecfg}, $volid))[3]) {
> @@ -280,7 +347,10 @@ sub phase1 {
>  
>      my $rep_cfg = PVE::ReplicationConfig->new();
>  
> -    if (my $jobcfg = $rep_cfg->find_local_replication_job($vmid, $self->{node})) {
> +    if ($remote) {
> +	die "cannot remote-migrate replicated VM\n"
> +	    if $rep_cfg->check_for_existing_jobs($vmid, 1);
> +    } elsif (my $jobcfg = $rep_cfg->find_local_replication_job($vmid, $self->{node})) {
>  	die "can't live migrate VM with replicated volumes\n" if $self->{running};
>  	my $start_time = time();
>  	my $logfunc = sub { my ($msg) = @_;  $self->log('info', $msg); };
> @@ -291,7 +361,6 @@ sub phase1 {
>      my $opts = $self->{opts};
>      foreach my $volid (keys %$volhash) {
>  	next if $rep_volumes->{$volid};
> -	my ($sid, $volname) = PVE::Storage::parse_volume_id($volid);
>  	push @{$self->{volumes}}, $volid;
>  
>  	# JSONSchema and get_bandwidth_limit use kbps - storage_migrate bps
> @@ -301,22 +370,39 @@ sub phase1 {
>  	my $targetsid = $volhash->{$volid}->{targetsid};
>  
>  	my $new_volid = eval {
> -	    my $storage_migrate_opts = {
> -		'ratelimit_bps' => $bwlimit,
> -		'insecure' => $opts->{migration_type} eq 'insecure',
> -		'with_snapshots' => $volhash->{$volid}->{snapshots},
> -		'allow_rename' => 1,
> -	    };
> -
> -	    my $logfunc = sub { $self->log('info', $_[0]); };
> -	    return PVE::Storage::storage_migrate(
> -		$self->{storecfg},
> -		$volid,
> -		$self->{ssh_info},
> -		$targetsid,
> -		$storage_migrate_opts,
> -		$logfunc,
> -	    );
> +	    if ($remote) {
> +		my $log = sub {
> +		    my ($level, $msg) = @_;
> +		    $self->log($level, $msg);
> +		};
> +
> +		return PVE::StorageTunnel::storage_migrate(
> +		    $self->{tunnel},
> +		    $self->{storecfg},
> +		    $volid,
> +		    $self->{vmid},
> +		    $remote->{vmid},
> +		    $volhash->{$volid},
> +		    $log,
> +		);
> +	    } else {
> +		my $storage_migrate_opts = {
> +		    'ratelimit_bps' => $bwlimit,
> +		    'insecure' => $opts->{migration_type} eq 'insecure',
> +		    'with_snapshots' => $volhash->{$volid}->{snapshots},
> +		    'allow_rename' => 1,
> +		};
> +
> +		my $logfunc = sub { $self->log('info', $_[0]); };
> +		return PVE::Storage::storage_migrate(
> +		    $self->{storecfg},
> +		    $volid,
> +		    $self->{ssh_info},
> +		    $targetsid,
> +		    $storage_migrate_opts,
> +		    $logfunc,
> +		);
> +	    }
>  	};
>  
>  	if (my $err = $@) {
> @@ -346,13 +432,38 @@ sub phase1 {
>      my $vollist = PVE::LXC::Config->get_vm_volumes($conf);
>      PVE::Storage::deactivate_volumes($self->{storecfg}, $vollist);
>  
> -    # transfer replication state before moving config
> -    $self->transfer_replication_state() if $rep_volumes;
> -    PVE::LXC::Config->update_volume_ids($conf, $self->{volume_map});
> -    PVE::LXC::Config->write_config($vmid, $conf);
> -    PVE::LXC::Config->move_config_to_node($vmid, $self->{node});
> +    if ($remote) {
> +	my $remote_conf = PVE::LXC::Config->load_config($vmid);
> +	PVE::LXC::Config->update_volume_ids($remote_conf, $self->{volume_map});
> +
> +	my $bridges = map_bridges($remote_conf, $self->{opts}->{bridgemap});
> +	for my $target (keys $bridges->%*) {
> +	    for my $nic (keys $bridges->{$target}->%*) {
> +		$self->log('info', "mapped: $nic from $bridges->{$target}->{$nic} to $target");
> +	    }
> +	}
> +	my $conf_str = PVE::LXC::Config::write_pct_config("remote", $remote_conf);
> +
> +	# TODO expose in PVE::Firewall?
> +	my $vm_fw_conf_path = "/etc/pve/firewall/$vmid.fw";
> +	my $fw_conf_str;
> +	$fw_conf_str = PVE::Tools::file_get_contents($vm_fw_conf_path)
> +	    if -e $vm_fw_conf_path;
> +	my $params = {
> +	    conf => $conf_str,
> +	    'firewall-config' => $fw_conf_str,
> +	};
> +
> +	PVE::Tunnel::write_tunnel($self->{tunnel}, 10, 'config', $params);
> +    } else {
> +	# transfer replication state before moving config
> +	$self->transfer_replication_state() if $rep_volumes;
> +	PVE::LXC::Config->update_volume_ids($conf, $self->{volume_map});
> +	PVE::LXC::Config->write_config($vmid, $conf);
> +	PVE::LXC::Config->move_config_to_node($vmid, $self->{node});
> +	$self->switch_replication_job_target() if $rep_volumes;
> +    }
>      $self->{conf_migrated} = 1;
> -    $self->switch_replication_job_target() if $rep_volumes;
>  }
>  
>  sub phase1_cleanup {
> @@ -366,6 +477,12 @@ sub phase1_cleanup {
>  	    # fixme: try to remove ?
>  	}
>      }
> +
> +    if ($self->{opts}->{remote}) {
> +	# cleans up remote volumes
> +	PVE::Tunnel::finish_tunnel($self->{tunnel}, 1);
> +	delete $self->{tunnel};
> +    }
>  }
>  
>  sub phase3 {
> @@ -373,6 +490,9 @@ sub phase3 {
>  
>      my $volids = $self->{volumes};
>  
> +    # handled below in final_cleanup
> +    return if $self->{opts}->{remote};
> +
>      # destroy local copies
>      foreach my $volid (@$volids) {
>  	eval { PVE::Storage::vdisk_free($self->{storecfg}, $volid); };
> @@ -401,6 +521,24 @@ sub final_cleanup {
>  	    my $skiplock = 1;
>  	    PVE::LXC::vm_start($vmid, $self->{vmconf}, $skiplock);
>  	}
> +    } elsif ($self->{opts}->{remote}) {
> +	eval { PVE::Tunnel::write_tunnel($self->{tunnel}, 10, 'unlock') };
> +	$self->log('err', "Failed to clear migrate lock - $@\n") if $@;
> +
> +	if ($self->{opts}->{restart} && $self->{was_running}) {
> +	    $self->log('info', "start container on target node");
> +	    PVE::Tunnel::write_tunnel($self->{tunnel}, 60, 'start');
> +	}
> +	if ($self->{opts}->{delete}) {
> +	    PVE::LXC::destroy_lxc_container(
> +		PVE::Storage::config(),
> +		$vmid,
> +		PVE::LXC::Config->load_config($vmid),
> +		undef,
> +		0,
> +	    );
> +	}
> +	PVE::Tunnel::finish_tunnel($self->{tunnel});
>      } else {
>  	my $cmd = [ @{$self->{rem_ssh}}, 'pct', 'unlock', $vmid ];
>  	$self->cmd_logerr($cmd, errmsg => "failed to clear migrate lock");
> @@ -413,7 +551,30 @@ sub final_cleanup {
>  	    $self->cmd($cmd);
>  	}
>      }
> +}
> +
> +sub map_bridges {
> +    my ($conf, $map, $scan_only) = @_;
> +
> +    my $bridges = {};
> +
> +    foreach my $opt (keys %$conf) {
> +	next if $opt !~ m/^net\d+$/;
> +
> +	next if !$conf->{$opt};
> +	my $d = PVE::LXC::Config->parse_lxc_network($conf->{$opt});
> +	next if !$d || !$d->{bridge};
> +
> +	my $target_bridge = PVE::JSONSchema::map_id($map, $d->{bridge});
> +	$bridges->{$target_bridge}->{$opt} = $d->{bridge};
> +
> +	next if $scan_only;
> +
> +	$d->{bridge} = $target_bridge;
> +	$conf->{$opt} = PVE::LXC::Config->print_lxc_network($d);
> +    }
>  
> +    return $bridges;
>  }
>  
>  1;
> -- 
> 2.30.2
> 
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel at lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 





More information about the pve-devel mailing list