public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint
@ 2026-08-07  9:12 Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 1/5] remote migration: drop ineffective fingerprint auto-detection Erik Fastermann
                   ` (4 more replies)
  0 siblings, 5 replies; 6+ messages in thread
From: Erik Fastermann @ 2026-08-07  9:12 UTC (permalink / raw)
  To: pve-devel; +Cc: Erik Fastermann

This series reworks how the QEMU remote-migration precondition checks
are structured and exposes them through a new endpoint, so blockers and
warnings can be surfaced before a migration is started rather than
mid-flight.

Thanks to Fiona, Fabian and Daniel for the review and the discussion on
the RFC [0].

Motivation
==========

Today the checks run at the very start of a remote migration and abort
on the first error via die. Two problems follow:

- Many prerequisites are not checked up front at all; they only surface
  once the migration is already running, e.g. local/mapped devices or a
  VNC clipboard that is not live-migratable. A user fixes one blocker,
  retries, and hits the next. A forum user collected a checklist of such
  prerequisites [1] (thanks Arthur Bied-Charreton for pointing this
  out).

- The qm CLI wrapper only ran a subset of the checks the API path ran,
  so direct API callers (e.g. the web UI) and the CLI disagreed on what
  was validated.

What the series does
====================

1. Drop the ineffective fingerprint auto-detection.

2. Extract the checks into a validate_remote_migrate_preconditions
   helper that records findings instead of dying on the first error.

3. Pull the checks that previously lived only in the qm CLI into the
   endpoint.

4. Register the remote-migrate command against PVE::API2::Qemu
   directly and drop the CLI wrapper, so CLI and API run the exact same
   checks.

5. Add a remote_migrate_vm_precondition endpoint that runs the checks
   without starting a migration and returns the full findings list. It
   reuses the same helper as the migrate endpoint, so the precheck
   cannot drift from what is actually enforced.

Changes since the RFC
=====================

- Pulling the qm-only checks into the endpoint is now its own patch
  (3/5) instead of being folded into the extraction patch.

- The finding field naming the check is now 'type' instead of 'code',
  matching the {storage}/import-metadata endpoint.

- $add_error and $add_warning are now thin wrappers around a shared
  $add_finding, all taking proper named arguments with a %extra_info
  hash as the last one, rather than indexing into @_.

- $plan is renamed to $migration_info. The findings array is still
  passed in, as agreed on the RFC.

- $remote_migrate_vm_properties is renamed to
  $remote_migrate_vm_parameters and passed to both endpoints directly
  instead of being shallow-copied per endpoint.

- The new endpoint is registered in the vmdiridx listing, so it shows
  up in the API directory index next to remote_migrate.

- The migrate endpoint no longer aborts on the first finding. All
  warnings are emitted via log_warn, all errors are collected, and the
  die message reports how many errors were found followed by the full
  list, which matches other instances in the codebase.

- Failures of the remote /cluster/resources and /nodes/localhost/storage
  queries are now recorded as 'remote-query' findings instead of dying,
  so a partially reachable remote still yields the checks that did run.
  The initial connectivity probe uses the same type, which is why it was
  renamed from 'remote-conn'.

- Document in the commit message that the HA check no longer uses
  raise_param_exc. This could be discussed more, as this changes the
  error code from 400 to 500, but unifies the interface for all errors. 

- chomp() instead of the s/\s+$//r regex, if (my $err = $@) instead of
  bare $@ checks, for instead of foreach, and postfix dereference.

- Further rewording of user-facing messages.

- The commit message prefix is now "remote migration:", matching the
  existing history in this repo.

Future work
===========

Nothing below is directly part of this series, but is the direction
agreed on in the RFC thread.

- Capability negotiation over the tunnel. A capabilities command lets
  the source detect whether the target understands the newer commands,
  falling back to the current source-side checks when it does not.

- A precondition tunnel command on top of that, where the source sends
  the relevant config and the target runs the checks it can answer
  better than the source can. Issued both by the precondition endpoint
  and by the migration itself.

- The existing source-side checks stay regardless. They remain the only
  thing that runs against a target without the capability, so removing
  them would mean no checks at all for new to old migrations.

- More checks moved up front, e.g. local/mapped devices and other
  things that currently only surface mid-migration. New checks that need
  target-side knowledge go through the tunnel command, purely
  source-config-derived ones into the shared helper this series adds.

- A similar series for remote container migration. Containers don't
  support live migration, so it should be simpler.

- The findings shape is meant to be reusable for the intra-cluster
  migration precondition endpoint later, as a map from node to findings
  list.

References
==========

[0] https://lore.proxmox.com/pve-devel/20260721115827.163442-1-e.fastermann@proxmox.com/
[1] https://forum.proxmox.com/threads/pdm-cross-cluster-migration-prerequisites-checklist-i-wish-i-had-before-my-first-attempt.184485


Erik Fastermann (5):
  remote migration: drop ineffective fingerprint auto-detection
  remote migration: collect preconditions as structured findings
  remote migration: pull in checks from qm
  qm: remote-migrate: call API endpoint directly
  remote migration: add precondition check endpoint

 src/PVE/API2/Qemu.pm | 490 ++++++++++++++++++++++++++++++-------------
 src/PVE/CLI/qm.pm    | 128 +----------
 2 files changed, 343 insertions(+), 275 deletions(-)

-- 
2.47.3




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

* [PATCH qemu-server v2 1/5] remote migration: drop ineffective fingerprint auto-detection
  2026-08-07  9:12 [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint Erik Fastermann
@ 2026-08-07  9:12 ` Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 2/5] remote migration: collect preconditions as structured findings Erik Fastermann
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 6+ messages in thread
From: Erik Fastermann @ 2026-08-07  9:12 UTC (permalink / raw)
  To: pve-devel; +Cc: Erik Fastermann

When no fingerprint was supplied, the code fetched the remote node
certificate and pinned its fingerprint for the migration tunnel. This
has no functional effect and is removed.

That fetch only succeeds for remotes trusted via the CA store, which
is exactly the case where pinning is unnecessary: both the API client
and the websocket tunnel fall back to CA verification on their own.
For a self-signed remote the fetch itself fails verification, so no
fingerprint is ever obtained.

The only meaningful path, an explicitly supplied fingerprint (needed to
verify self-signed remotes), is unchanged.

Signed-off-by: Erik Fastermann <e.fastermann@proxmox.com>
---

 See the discussion on the RFC for more details [0].

 [0]: https://lore.proxmox.com/pve-devel/1785319378.5wn648ra0k.astroid@yuna.none/


 src/PVE/API2/Qemu.pm | 16 ++--------------
 1 file changed, 2 insertions(+), 14 deletions(-)

diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index 39c725d0..56c5f08c 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -5751,26 +5751,14 @@ __PACKAGE__->register_method({
             apitoken => $remote->{apitoken},
         };
 
-        my $fp;
-        if ($fp = $remote->{fingerprint}) {
-            $conn_args->{cached_fingerprints} = { uc($fp) => 1 };
+        if ($remote->{fingerprint}) {
+            $conn_args->{cached_fingerprints} = { uc($remote->{fingerprint}) => 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 $repl_conf = PVE::ReplicationConfig->new();
         my $is_replicated = $repl_conf->check_for_existing_jobs($source_vmid, 1);
         die "cannot remote-migrate replicated VM\n" if $is_replicated;
-- 
2.47.3




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

* [PATCH qemu-server v2 2/5] remote migration: collect preconditions as structured findings
  2026-08-07  9:12 [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 1/5] remote migration: drop ineffective fingerprint auto-detection Erik Fastermann
@ 2026-08-07  9:12 ` Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 3/5] remote migration: pull in checks from qm Erik Fastermann
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 6+ messages in thread
From: Erik Fastermann @ 2026-08-07  9:12 UTC (permalink / raw)
  To: pve-devel; +Cc: Erik Fastermann

Extract the remote-migration precondition checks into a
validate_remote_migrate_preconditions helper that records each problem
as a {severity, type, message} finding and returns the derived migration
parameters. This prepares for a precondition endpoint that returns the
full findings list instead of dying on errors.

User-visible effect is minimal and successful migrations are unchanged.
Some precondition error messages are reworded and the "Establishing
API connection" info line is no longer printed, as this would be noise
in the precondition endpoint. Precondition warnings now use log_warn and
the collected precondition errors are all included in the die message.
The HA check no longer uses raise_param_exc, which unifies the
interface.

Signed-off-by: Erik Fastermann <e.fastermann@proxmox.com>
---
 src/PVE/API2/Qemu.pm | 280 +++++++++++++++++++++++++++++--------------
 1 file changed, 189 insertions(+), 91 deletions(-)

diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index 56c5f08c..b16a1365 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -1063,6 +1063,157 @@ sub assert_scsi_feature_compatibility {
     }
 }
 
+my sub validate_remote_migrate_preconditions {
+    my ($param, $findings) = @_;
+
+    my $add_finding = sub {
+        my ($severity, $type, $message, %extra_info) = @_;
+        chomp($message);
+
+        push @$findings,
+            {
+                severity => $severity,
+                type => $type,
+                message => $message,
+                %extra_info,
+            };
+    };
+
+    my $add_error = sub { $add_finding->('error', @_); };
+    my $add_warning = sub { $add_finding->('warning', @_); };
+
+    my $source_vmid = extract_param($param, 'vmid');
+    my $target_endpoint = extract_param($param, 'target-endpoint');
+    my $remote = PVE::JSONSchema::parse_property_string('proxmox-remote', $target_endpoint);
+    my $target_vmid = extract_param($param, 'target-vmid') // $source_vmid;
+
+    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 $@;
+
+    my $delete = extract_param($param, 'delete') // 0;
+
+    PVE::Cluster::check_cfs_quorum();
+
+    # test if VM exists
+    my $conf = eval { PVE::QemuConfig->load_config($source_vmid) };
+    if (my $err = $@) {
+        $add_error->('load-vm-config', $err);
+        return;
+    }
+
+    if (PVE::QemuConfig->has_lock($conf)) {
+        $add_error->('vm-locked', "VM is locked ($conf->{lock})");
+    }
+
+    if (PVE::HA::Config::service_is_configured("vm:$source_vmid")) {
+        $add_error->('vm-ha-configured', 'cannot remote-migrate VM that is configured for HA');
+    }
+
+    my $conn_args = {
+        protocol => 'https',
+        host => $remote->{host},
+        port => $remote->{port} // 8006,
+        apitoken => $remote->{apitoken},
+    };
+
+    if ($remote->{fingerprint}) {
+        $conn_args->{cached_fingerprints} = { uc($remote->{fingerprint}) => 1 };
+    }
+
+    my $api_client = PVE::APIClient::LWP->new(%$conn_args);
+
+    # check connection once at the start
+    eval { $api_client->get("/version") };
+    if (my $err = $@) {
+        $add_error->('remote-query', $err);
+        return;
+    }
+
+    my $repl_conf = PVE::ReplicationConfig->new();
+    my $is_replicated = $repl_conf->check_for_existing_jobs($source_vmid, 1);
+    $add_error->('vm-replicated', "cannot remote-migrate replicated VM")
+        if $is_replicated;
+
+    if (PVE::QemuServer::check_running($source_vmid)) {
+        $add_error->(
+            'offline-migration-vm-running',
+            "cannot migrate running VM without online option",
+        ) if !$param->{online};
+    } else {
+        $add_warning->(
+            'online-migration-vm-not-running',
+            "VM isn't running, migrating offline instead",
+        ) if $param->{online};
+        $param->{online} = 0;
+    }
+
+    my $check_custom_cpu = sub {
+        return if !defined($conf->{cpu});
+
+        my $cpu = PVE::JSONSchema::parse_property_string('pve-vm-cpu-conf', $conf->{cpu});
+        my $cputype = $cpu->{cputype};
+        return if !defined($cputype) || !PVE::QemuServer::CPUConfig::is_custom_model($cputype);
+
+        my $custom_cpu = PVE::QemuServer::CPUConfig::get_custom_model($cputype);
+
+        my $remote_custom_cpu = eval {
+            $api_client->get(
+                "/cluster/qemu/custom-cpu-models/" . URI::Escape::uri_escape_utf8($cputype));
+        };
+
+        if (my $err = $@) {
+            $add_error->(
+                'custom-cpu-validation',
+                "could not validate custom CPU model compatibility: $err",
+            );
+            return;
+        }
+
+        my $cpu_schema = {
+            type => 'object',
+            properties => PVE::QemuServer::CPUConfig->options(),
+        };
+        eval { PVE::JSONSchema::validate($remote_custom_cpu, $cpu_schema); };
+
+        if (my $err = $@) {
+            $add_error->(
+                'custom-cpu-validation',
+                "could not validate custom CPU model compatibility: $err",
+            );
+            return;
+        }
+
+        eval {
+            PVE::QemuServer::CPUConfig::assert_custom_model_compatibility(
+                $custom_cpu, $remote_custom_cpu,
+            );
+        };
+
+        $add_error->('custom-cpu-mismatch', $@) if $@;
+    };
+    $check_custom_cpu->();
+
+    $add_error->('storage-mapping', "remote migration requires explicit storage mapping")
+        if $storagemap->{identity};
+
+    return {
+        conn_args => $conn_args,
+        api_client => $api_client,
+        source_vmid => $source_vmid,
+        target_vmid => $target_vmid,
+        storagemap => $storagemap,
+        bridgemap => $bridgemap,
+        delete => $delete,
+    };
+}
+
 __PACKAGE__->register_method({
     name => 'vmlist',
     path => '',
@@ -5725,106 +5876,36 @@ __PACKAGE__->register_method({
         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 VM exists
-        my $conf = PVE::QemuConfig->load_config($source_vmid);
-
-        PVE::QemuConfig->check_lock($conf);
+        my $findings = [];
+        my $migration_info = validate_remote_migrate_preconditions($param, $findings);
 
-        raise_param_exc({ vmid => "cannot remote-migrate VM that is configured for HA" })
-            if PVE::HA::Config::service_is_configured("vm:$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 $errors = '';
+        my $error_count = 0;
 
-        if ($remote->{fingerprint}) {
-            $conn_args->{cached_fingerprints} = { uc($remote->{fingerprint}) => 1 };
+        for my $finding (@$findings) {
+            next if $finding->{severity} ne 'error';
+            $errors .= "- $finding->{message}\n";
+            $error_count++;
         }
 
-        print "Establishing API connection with remote at '$remote->{host}'\n";
-
-        my $api_client = PVE::APIClient::LWP->new(%$conn_args);
-
-        my $repl_conf = PVE::ReplicationConfig->new();
-        my $is_replicated = $repl_conf->check_for_existing_jobs($source_vmid, 1);
-        die "cannot remote-migrate replicated VM\n" if $is_replicated;
-
-        if (PVE::QemuServer::check_running($source_vmid)) {
-            die "can't migrate running VM without --online\n" if !$param->{online};
-
-        } else {
-            warn "VM isn't running. Doing offline migration instead.\n" if $param->{online};
-            $param->{online} = 0;
-        }
-
-        if (defined($conf->{cpu})) {
-            my $cpu = PVE::JSONSchema::parse_property_string('pve-vm-cpu-conf', $conf->{cpu});
-            my $cputype = $cpu->{cputype};
-            if (defined($cputype) && PVE::QemuServer::CPUConfig::is_custom_model($cputype)) {
-                my $custom_cpu = PVE::QemuServer::CPUConfig::get_custom_model($cputype);
-
-                my $remote_custom_cpu = eval {
-                    $api_client->get("/cluster/qemu/custom-cpu-models/"
-                        . URI::Escape::uri_escape_utf8($cputype));
-                };
-                die "could not validate custom CPU model compatibility: $@\n" if $@;
-
-                my $cpu_schema = {
-                    type => 'object',
-                    properties => PVE::QemuServer::CPUConfig->options(),
-                };
-                eval { PVE::JSONSchema::validate($remote_custom_cpu, $cpu_schema); };
-                die "could not validate custom CPU model compatibility: $@\n" if $@;
-
-                PVE::QemuServer::CPUConfig::assert_custom_model_compatibility(
-                    $custom_cpu, $remote_custom_cpu,
-                );
-            }
+        if ($errors) {
+            die "detected $error_count error(s) preventing remote migration:\n" . $errors;
         }
 
-        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->{storagemap} = $migration_info->{storagemap};
+        $param->{bridgemap} = $migration_info->{bridgemap};
         $param->{remote} = {
-            conn => $conn_args, # re-use fingerprint for tunnel
-            client => $api_client,
-            vmid => $target_vmid,
+            conn => $migration_info->{conn_args}, # re-use fingerprint for tunnel
+            client => $migration_info->{api_client},
+            vmid => $migration_info->{target_vmid},
         };
         $param->{migration_type} = 'websocket';
         $param->{'with-local-disks'} = 1;
-        $param->{delete} = $delete if $delete;
+        $param->{delete} = $migration_info->{delete} if $migration_info->{delete};
 
-        my $cluster_status = $api_client->get("/cluster/status");
+        my $cluster_status = $migration_info->{api_client}->get("/cluster/status");
         my $target_node;
-        foreach my $entry (@$cluster_status) {
+        for my $entry (@$cluster_status) {
             next if $entry->{type} ne 'node';
             if ($entry->{local}) {
                 $target_node = $entry->{name};
@@ -5836,14 +5917,31 @@ __PACKAGE__->register_method({
             if !defined($target_node);
 
         my $realcmd = sub {
-            PVE::QemuMigrate->migrate($target_node, $remote->{host}, $source_vmid, $param);
+            PVE::QemuMigrate->migrate(
+                $target_node,
+                $migration_info->{conn_args}->{host},
+                $migration_info->{source_vmid},
+                $param,
+            );
         };
 
         my $worker = sub {
-            return PVE::GuestHelpers::guest_migration_lock($source_vmid, 10, $realcmd);
+            # warn only in the worker, so the messages reach the task log and
+            # the task's warning count matches what is logged
+            for my $finding (@$findings) {
+                log_warn($finding->{message}) if $finding->{severity} eq 'warning';
+            }
+
+            return PVE::GuestHelpers::guest_migration_lock(
+                $migration_info->{source_vmid},
+                10,
+                $realcmd,
+            );
         };
 
-        return $rpcenv->fork_worker('qmigrate', $source_vmid, $authuser, $worker);
+        return $rpcenv->fork_worker(
+            'qmigrate', $migration_info->{source_vmid}, $authuser, $worker,
+        );
     },
 });
 
-- 
2.47.3




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

* [PATCH qemu-server v2 3/5] remote migration: pull in checks from qm
  2026-08-07  9:12 [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 1/5] remote migration: drop ineffective fingerprint auto-detection Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 2/5] remote migration: collect preconditions as structured findings Erik Fastermann
@ 2026-08-07  9:12 ` Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 4/5] qm: remote-migrate: call API endpoint directly Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 5/5] remote migration: add precondition check endpoint Erik Fastermann
  4 siblings, 0 replies; 6+ messages in thread
From: Erik Fastermann @ 2026-08-07  9:12 UTC (permalink / raw)
  To: pve-devel; +Cc: Erik Fastermann

Pull in the checks that previously lived only in the qm CLI into the
endpoint. Direct API callers such as the web UI now run them too, so
fewer migrations start only to fail partway.

The logic is mostly identical. Error messages are reworded slightly and
errors are collected instead of dying on the first one.

Signed-off-by: Erik Fastermann <e.fastermann@proxmox.com>
---
 src/PVE/API2/Qemu.pm | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)

diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index b16a1365..1d94babd 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -1136,6 +1136,47 @@ my sub validate_remote_migrate_preconditions {
         return;
     }
 
+    my $resources = eval { $api_client->get("/cluster/resources", { type => 'vm' }) };
+    if (my $err = $@) {
+        $add_error->('remote-query', $err);
+    } elsif (grep { defined($_->{vmid}) && $_->{vmid} eq $target_vmid } @$resources) {
+        $add_error->('target-vmid-exists', "remote: guest with ID '$target_vmid' already exists");
+    }
+
+    my $storages = eval { $api_client->get("/nodes/localhost/storage", { enabled => 1 }) };
+    if (my $err = $@) {
+        $add_error->('remote-query', $err);
+    } else {
+        my $check_remote_storage = sub {
+            my ($storage) = @_;
+            my $found = [grep { $_->{storage} eq $storage } @$storages];
+
+            if (!@$found) {
+                $add_error->(
+                    'storage-missing',
+                    "remote: storage '$storage' does not exist (or missing permission)",
+                    storage => $storage,
+                );
+                return;
+            }
+
+            $found = $found->[0];
+            my $content_types = [PVE::Tools::split_list($found->{content})];
+            $add_error->(
+                'storage-no-images',
+                "remote: storage '$storage' cannot store images",
+                storage => $storage,
+            ) if !grep { $_ eq 'images' } @$content_types;
+        };
+
+        for my $target_sid (values $storagemap->{entries}->%*) {
+            $check_remote_storage->($target_sid);
+        }
+
+        $check_remote_storage->($storagemap->{default})
+            if $storagemap->{default};
+    }
+
     my $repl_conf = PVE::ReplicationConfig->new();
     my $is_replicated = $repl_conf->check_for_existing_jobs($source_vmid, 1);
     $add_error->('vm-replicated', "cannot remote-migrate replicated VM")
-- 
2.47.3




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

* [PATCH qemu-server v2 4/5] qm: remote-migrate: call API endpoint directly
  2026-08-07  9:12 [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint Erik Fastermann
                   ` (2 preceding siblings ...)
  2026-08-07  9:12 ` [PATCH qemu-server v2 3/5] remote migration: pull in checks from qm Erik Fastermann
@ 2026-08-07  9:12 ` Erik Fastermann
  2026-08-07  9:12 ` [PATCH qemu-server v2 5/5] remote migration: add precondition check endpoint Erik Fastermann
  4 siblings, 0 replies; 6+ messages in thread
From: Erik Fastermann @ 2026-08-07  9:12 UTC (permalink / raw)
  To: pve-devel; +Cc: Erik Fastermann

The command wrapper only ran a subset of the precondition checks the
endpoint now performs itself. Register the 'remote-migrate' command
against PVE::API2::Qemu directly, as done for 'migrate' and the other
commands, and drop the wrapper.

Also remove the now unused PVE::APIClient::LWP import.

Signed-off-by: Erik Fastermann <e.fastermann@proxmox.com>
---
 src/PVE/CLI/qm.pm | 128 +---------------------------------------------
 1 file changed, 1 insertion(+), 127 deletions(-)

diff --git a/src/PVE/CLI/qm.pm b/src/PVE/CLI/qm.pm
index b903c1f1..ec87972c 100755
--- a/src/PVE/CLI/qm.pm
+++ b/src/PVE/CLI/qm.pm
@@ -15,7 +15,6 @@ use POSIX qw(strftime);
 use Term::ReadLine;
 use URI::Escape;
 
-use PVE::APIClient::LWP;
 use PVE::Cluster;
 use PVE::Exception qw(raise_param_exc);
 use PVE::GuestHelpers;
@@ -172,131 +171,6 @@ __PACKAGE__->register_method({
     },
 });
 
-__PACKAGE__->register_method({
-    name => 'remote_migrate_vm',
-    path => 'remote_migrate_vm',
-    method => 'POST',
-    description =>
-        "Migrate virtual machine to a remote 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::QemuServer::complete_vmid }),
-            '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 if VM is running. Ignored if VM is stopped.",
-                optional => 1,
-            },
-            delete => {
-                type => 'boolean',
-                description =>
-                    "Delete the original VM and related data after successful migration. By default the original VM is kept on the source cluster in a stopped state.",
-                optional => 1,
-                default => 0,
-            },
-            'target-storage' => get_standard_option(
-                'pve-targetstorage',
-                {
-                    completion => \&PVE::QemuServer::complete_migration_storage,
-                    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 => 'integer',
-                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 = $param->{vmid};
-        my $target_endpoint = $param->{'target-endpoint'};
-        my $target_vmid = $param->{'target-vmid'} // $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},
-        };
-
-        $conn_args->{cached_fingerprints} = { uc($remote->{fingerprint}) => 1 }
-            if defined($remote->{fingerprint});
-
-        my $api_client = PVE::APIClient::LWP->new(%$conn_args);
-        my $resources = $api_client->get("/cluster/resources", { type => 'vm' });
-        if (grep { defined($_->{vmid}) && $_->{vmid} eq $target_vmid } @$resources) {
-            raise_param_exc(
-                {
-                    target_vmid =>
-                        "Guest with ID '$target_vmid' already exists on remote cluster",
-                },
-            );
-        }
-
-        my $storages = $api_client->get("/nodes/localhost/storage", { enabled => 1 });
-
-        my $storecfg = PVE::Storage::config();
-        my $target_storage = $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 $check_remote_storage = sub {
-            my ($storage) = @_;
-            my $found = [grep { $_->{storage} eq $storage } @$storages];
-            die "remote: storage '$storage' does not exist (or missing permission)!\n"
-                if !@$found;
-
-            $found = @$found[0];
-
-            my $content_types = [PVE::Tools::split_list($found->{content})];
-            die "remote: storage '$storage' cannot store images\n"
-                if !grep { $_ eq 'images' } @$content_types;
-        };
-
-        foreach my $target_sid (values %{ $storagemap->{entries} }) {
-            $check_remote_storage->($target_sid);
-        }
-
-        $check_remote_storage->($storagemap->{default})
-            if $storagemap->{default};
-
-        return PVE::API2::Qemu->remote_migrate_vm($param);
-    },
-});
-
 __PACKAGE__->register_method({
     name => 'status',
     path => 'status',
@@ -1361,7 +1235,7 @@ our $cmddef = {
 
     migrate => ["PVE::API2::Qemu", 'migrate_vm', ['vmid', 'target'], {%node}, $upid_exit],
     'remote-migrate' => [
-        __PACKAGE__,
+        "PVE::API2::Qemu",
         'remote_migrate_vm',
         ['vmid', 'target-vmid', 'target-endpoint'],
         {%node},
-- 
2.47.3




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

* [PATCH qemu-server v2 5/5] remote migration: add precondition check endpoint
  2026-08-07  9:12 [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint Erik Fastermann
                   ` (3 preceding siblings ...)
  2026-08-07  9:12 ` [PATCH qemu-server v2 4/5] qm: remote-migrate: call API endpoint directly Erik Fastermann
@ 2026-08-07  9:12 ` Erik Fastermann
  4 siblings, 0 replies; 6+ messages in thread
From: Erik Fastermann @ 2026-08-07  9:12 UTC (permalink / raw)
  To: pve-devel; +Cc: Erik Fastermann

Add a remote_migrate_vm_precondition endpoint that runs the remote
migration precondition checks without starting a migration and returns
them as a list of {severity, type, message} findings, so callers such
as the web UI can surface blockers and warnings before the user commits
to a migration.

It reuses the same validate_remote_migrate_preconditions helper the
migrate endpoint uses, so the precheck cannot drift from what is
actually enforced. The endpoint is POST rather than GET, on its own
path, to keep the remote API token out of request URLs and the access
log.

Suggested-by: Dominik Csapak <d.csapak@proxmox.com>
Signed-off-by: Erik Fastermann <e.fastermann@proxmox.com>
---
 src/PVE/API2/Qemu.pm | 157 ++++++++++++++++++++++++++++++-------------
 1 file changed, 112 insertions(+), 45 deletions(-)

diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index 1d94babd..ce6c06f5 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -1063,6 +1063,53 @@ sub assert_scsi_feature_compatibility {
     }
 }
 
+my $remote_migrate_vm_parameters = {
+    additionalProperties => 0,
+    properties => {
+        node => get_standard_option('pve-node'),
+        vmid =>
+            get_standard_option('pve-vmid', { completion => \&PVE::QemuServer::complete_vmid }),
+        '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 if VM is running. Ignored if VM is stopped.",
+            optional => 1,
+        },
+        delete => {
+            type => 'boolean',
+            description => "Delete the original VM and related data after successful migration."
+                . " By default the original VM is kept on the source cluster in a stopped state.",
+            optional => 1,
+            default => 0,
+        },
+        'target-storage' => get_standard_option(
+            'pve-targetstorage',
+            {
+                completion => \&PVE::QemuServer::complete_migration_storage,
+                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 => 'integer',
+            minimum => '0',
+            default => 'migrate limit from datacenter or storage config',
+        },
+    },
+};
+
 my sub validate_remote_migrate_preconditions {
     my ($param, $findings) = @_;
 
@@ -1862,6 +1909,7 @@ __PACKAGE__->register_method({
             { subdir => 'firewall' },
             { subdir => 'mtunnel' },
             { subdir => 'remote_migrate' },
+            { subdir => 'remote_migrate_precondition' },
         ];
 
         return $res;
@@ -5852,61 +5900,80 @@ __PACKAGE__->register_method({
 });
 
 __PACKAGE__->register_method({
-    name => 'remote_migrate_vm',
-    path => '{vmid}/remote_migrate',
+    name => 'remote_migrate_vm_precondition',
+    path => '{vmid}/remote_migrate_precondition',
     method => 'POST',
     protected => 1,
     proxyto => 'node',
-    description =>
-        "Migrate virtual machine to a remote cluster. Creates a new migration task. EXPERIMENTAL feature!",
+    description => "Get preconditions for remote migration.",
     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::QemuServer::complete_vmid }),
-            '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 if VM is running. Ignored if VM is stopped.",
-                optional => 1,
-            },
-            delete => {
-                type => 'boolean',
-                description =>
-                    "Delete the original VM and related data after successful migration. By default the original VM is kept on the source cluster in a stopped state.",
-                optional => 1,
-                default => 0,
-            },
-            'target-storage' => get_standard_option(
-                'pve-targetstorage',
-                {
-                    completion => \&PVE::QemuServer::complete_migration_storage,
-                    optional => 0,
+    parameters => $remote_migrate_vm_parameters,
+    returns => {
+        type => "array",
+        items => {
+            description => "A precondition finding.",
+            type => "object",
+            properties => {
+                severity => {
+                    description => "Severity of the finding.",
+                    type => 'string',
+                    enum => [qw(error warning)],
                 },
-            ),
-            '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 => 'integer',
-                minimum => '0',
-                default => 'migrate limit from datacenter or storage config',
+                type => {
+                    description => "Machine readable code of the finding.",
+                    type => 'string',
+                    enum => [qw(
+                        load-vm-config
+                        vm-locked
+                        vm-ha-configured
+                        remote-query
+                        target-vmid-exists
+                        storage-missing
+                        storage-no-images
+                        vm-replicated
+                        offline-migration-vm-running
+                        online-migration-vm-not-running
+                        custom-cpu-validation
+                        custom-cpu-mismatch
+                        storage-mapping
+                    )],
+                },
+                message => {
+                    description => "Human readable message.",
+                    type => 'string',
+                },
+                storage => get_standard_option(
+                    'pve-storage-id',
+                    {
+                        description => "Optional associated storage.",
+                        optional => 1,
+                    },
+                ),
             },
         },
     },
+    code => sub {
+        my ($param) = @_;
+        my $findings = [];
+        validate_remote_migrate_preconditions($param, $findings);
+        return $findings;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'remote_migrate_vm',
+    path => '{vmid}/remote_migrate',
+    method => 'POST',
+    protected => 1,
+    proxyto => 'node',
+    description =>
+        "Migrate virtual machine to a remote cluster. Creates a new migration task. EXPERIMENTAL feature!",
+    permissions => {
+        check => ['perm', '/vms/{vmid}', ['VM.Migrate']],
+    },
+    parameters => $remote_migrate_vm_parameters,
     returns => {
         type => 'string',
         description => "the task ID.",
-- 
2.47.3




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

end of thread, other threads:[~2026-08-07  9:13 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-07  9:12 [PATCH qemu-server v2 0/5] remote migration: extract preconditions and add check endpoint Erik Fastermann
2026-08-07  9:12 ` [PATCH qemu-server v2 1/5] remote migration: drop ineffective fingerprint auto-detection Erik Fastermann
2026-08-07  9:12 ` [PATCH qemu-server v2 2/5] remote migration: collect preconditions as structured findings Erik Fastermann
2026-08-07  9:12 ` [PATCH qemu-server v2 3/5] remote migration: pull in checks from qm Erik Fastermann
2026-08-07  9:12 ` [PATCH qemu-server v2 4/5] qm: remote-migrate: call API endpoint directly Erik Fastermann
2026-08-07  9:12 ` [PATCH qemu-server v2 5/5] remote migration: add precondition check endpoint Erik Fastermann

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal