From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id ECF141FF0C0 for ; Wed, 04 Nov 2026 20:07:33 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 741F4218DC; Wed, 04 Nov 2026 20:05:26 +0100 (CET) From: =?UTF-8?q?Michael=20K=C3=B6ppl?= To: pve-devel@lists.proxmox.com Subject: [PATCH e2e-tests 20/38] tests: vm: verify snapshots, clones, templates Date: Wed, 4 Nov 2026 20:03:27 +0100 Message-ID: <20261104190345.2573464-21-m.koeppl@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261104190345.2573464-1-m.koeppl@proxmox.com> References: <20261104190345.2573464-1-m.koeppl@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793819035188 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.221 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: TWPAH7UYBFHW5BDEGHPDSOVSBJPFR6ZG X-Message-ID-Hash: TWPAH7UYBFHW5BDEGHPDSOVSBJPFR6ZG X-MailFrom: m.koeppl@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Take a snapshot of a running VM including its RAM and check that a rollback restores the config from the time of the snapshot and resumes the VM right away, and that deleting the snapshot removes the saved RAM. Clone the running VM and a template made from it, and check that each clone gets its own disk and a new MAC address, SMBIOS UUID and VM generation ID, so that the clones can be told apart. Check that the template itself can no longer be started. The VM has no OS, so the test does not check disk contents. Signed-off-by: Michael Köppl --- tests/pve_vm_snapshot_clone.pl | 282 +++++++++++++++++++++++++++++++++ tests/testcases.ron | 9 ++ 2 files changed, 291 insertions(+) create mode 100755 tests/pve_vm_snapshot_clone.pl diff --git a/tests/pve_vm_snapshot_clone.pl b/tests/pve_vm_snapshot_clone.pl new file mode 100755 index 0000000..69eee60 --- /dev/null +++ b/tests/pve_vm_snapshot_clone.pl @@ -0,0 +1,282 @@ +#!/usr/bin/perl + +=head1 NAME + +C - Test snapshots, clones and templates of a VM. + +=head1 DESCRIPTION + +This testcase creates a VM with a disk on local-lvm and a network device, starts +it and takes a snapshot with RAM. After changing, adding and removing options and +leaving a change pending, it rolls the snapshot back and verifies that the VM +runs again right away (snapshot with RAM), that the config from the time of the +snapshot is restored with a new VM generation ID, and that the pending change is +discarded. Deleting the snapshot must also remove the saved VM state. + +Afterwards, it creates a full clone of the running VM and checks that the clone +has its own disk and its own identity (i.e. MAC address, SMBIOS UUID, VM +generation ID). Finally, it converts the VM to a template and creates a linked +clone of the template, which must get its own identity as well and start. + +The testcase does not check disk contents, as the VM has no operating system. + +=cut + +use strict; +use warnings; + +use Test::More; + +use lib '.'; +use Proxmox::Test::Helper qw(get_pve_instance); +use Proxmox::Test::Testcase; + +my $pve = get_pve_instance('pve1'); +my $node = $pve->get_nodename(); +my $client = $pve->client(); + +my $snapname = 'e2esnap'; + +my $test_vm = { + vmid => $client->get('/cluster/nextid'), + name => 'vm-snapshot', + memory => 256, + scsihw => 'virtio-scsi-pci', + scsi0 => 'local-lvm:1', + net0 => 'virtio,bridge=vmbr0', + tags => 'e2e', +}; + +my $vmid = $test_vm->{vmid}; +my $full_clone = { vmid => undef, name => 'vm-clone-full' }; +my $linked_clone = { vmid => undef, name => 'vm-clone-linked' }; +my $vmstate; +my $snapshot_conf; + +my $vm_config = sub { + my ($id) = @_; + return $client->get("/nodes/$node/qemu/$id/config", {}); +}; + +# The config in effect, without pending changes applied. +my $current_config = sub { + return $client->get("/nodes/$node/qemu/$vmid/config", { current => 1 }); +}; + +my $pending_changes = sub { + my $pending = $client->get("/nodes/$node/qemu/$vmid/pending", {}); + return { + map { $_->{key} => 1 } + grep { defined($_->{pending}) || $_->{delete} } $pending->@* + }; +}; + +my $vm_identity = sub { + my ($conf) = @_; + + my ($mac) = ($conf->{net0} // '') =~ m/^[^=,]+=([0-9A-Fa-f:]{17})\b/; + my ($uuid) = ($conf->{smbios1} // '') =~ m/\buuid=([0-9a-fA-F-]+)/; + + return { mac => $mac, uuid => $uuid, vmgenid => $conf->{vmgenid} }; +}; + +my $volume_exists = sub { + my ($volid, $owner) = @_; + + my ($storage) = $volid =~ m/^([^:]+):/; + my $content = $client->get("/nodes/$node/storage/$storage/content", { vmid => $owner }); + return grep { $_->{volid} eq $volid } $content->@*; +}; + +my $verify_clone = sub { + my ($clone, $source_conf) = @_; + my $id = $clone->{vmid}; + + my $conf = $vm_config->($id); + is($conf->{name}, $clone->{name}, "clone $id has its own name"); + is($conf->{memory}, $source_conf->{memory}, "clone $id has the memory of the source"); + + my ($disk) = ($conf->{scsi0} // '') =~ m/^([^,]+)/; + my ($source_disk) = ($source_conf->{scsi0} // '') =~ m/^([^,]+)/; + ok( + defined($disk) && $disk ne $source_disk && $volume_exists->($disk, $id), + "clone $id has its own disk", + ); + + my $clone_identity = $vm_identity->($conf); + my $source_identity = $vm_identity->($source_conf); + for my $key (sort keys $clone_identity->%*) { + ok(defined($clone_identity->{$key}), "clone $id has a $key"); + isnt($clone_identity->{$key}, $source_identity->{$key}, "clone $id has its own $key"); + } + + $pve->start_guest('qemu', $id); + $pve->stop_guest('qemu', $id); +}; + +Proxmox::Test::Testcase->new('pve_vm_snapshot_clone')->with([{}])->step( + 'create VM', + sub { + my $upid = $client->post("/nodes/$node/qemu", $test_vm); + $pve->task_ok($upid, "created VM $vmid") or die "creating VM $vmid failed\n"; + }, + sub { + $pve->destroy_guest('qemu', $vmid); + }, +)->step( + 'verify generated identity', + sub { + my $identity = $vm_identity->($vm_config->($vmid)); + + ok($identity->{mac}, 'a MAC address is generated for net0'); + ok($identity->{uuid}, 'an SMBIOS UUID is generated'); + ok($identity->{vmgenid}, 'a vmgenid is generated'); + }, +)->step( + 'start VM', + sub { + $pve->start_guest('qemu', $vmid); + }, +)->step( + 'create snapshot including RAM', + sub { + my $upid = $client->post( + "/nodes/$node/qemu/$vmid/snapshot", + { snapname => $snapname, vmstate => 1 }, + ); + $pve->task_ok($upid, "created snapshot '$snapname' of the running VM") + or die "creating snapshot '$snapname' failed\n"; + + my ($snapshot) = grep { $_->{name} eq $snapname } + $client->get("/nodes/$node/qemu/$vmid/snapshot", {})->@*; + ok($snapshot, "snapshot '$snapname' is listed"); + is($snapshot->{vmstate}, 1, "snapshot '$snapname' includes the RAM"); + + $vmstate = + $client->get("/nodes/$node/qemu/$vmid/snapshot/$snapname/config", {})->{vmstate}; + ok(defined($vmstate) && $volume_exists->($vmstate, $vmid), 'RAM is saved to a volume'); + + $snapshot_conf = $current_config->(); + }, +)->step( + 'change config after the snapshot', + sub { + # Changing the memory of a running VM without memory hotplug stays pending, the other + # changes apply right away. + $client->put( + "/nodes/$node/qemu/$vmid/config", + { name => 'vm-snapshot-changed', onboot => 1, delete => 'tags', memory => 512 }, + ); + + my $conf = $current_config->(); + is($conf->{name}, 'vm-snapshot-changed', 'name is changed'); + is($conf->{onboot}, 1, 'onboot is added'); + ok(!defined($conf->{tags}), 'tags are removed'); + is_deeply($pending_changes->(), { memory => 1 }, 'memory change is pending'); + }, +)->step( + 'rollback snapshot', + sub { + my ($since) = $pve->run_command('date +%s'); + chomp($since); + + my $upid = $client->post("/nodes/$node/qemu/$vmid/snapshot/$snapname/rollback", {}); + $pve->task_ok($upid, "rolled back to snapshot '$snapname'") + or die "rolling back snapshot '$snapname' failed\n"; + + is( + $client->get("/nodes/$node/qemu/$vmid/status/current", {})->{status}, + 'running', + 'VM runs again right after the rollback, as the snapshot includes its RAM', + ); + + my $conf = $current_config->(); + isnt($conf->{vmgenid}, $snapshot_conf->{vmgenid}, 'rollback generates a new vmgenid'); + my %expected = $snapshot_conf->%*; + # Rollback gives the VM a new generation ID, so this can never match. + delete $expected{$_} for qw(digest vmgenid); + delete $conf->{$_} for qw(digest vmgenid); + is_deeply( + $conf, + \%expected, + 'rollback restored the config from the time of the snapshot', + ); + is_deeply($pending_changes->(), {}, 'rollback discarded the pending change'); + + $pve->wait_for_qemu_cleanup($vmid, $since); + }, +)->step( + 'delete snapshot', + sub { + my $upid = $client->delete("/nodes/$node/qemu/$vmid/snapshot/$snapname", {}); + $pve->task_ok($upid, "deleted snapshot '$snapname'") + or die "deleting snapshot '$snapname' failed\n"; + + ok( + !( + grep { $_->{name} eq $snapname } + $client->get("/nodes/$node/qemu/$vmid/snapshot", {})->@* + ), + "snapshot '$snapname' is gone", + ); + ok(!$volume_exists->($vmstate, $vmid), 'saved VM state is removed with the snapshot'); + }, +)->step( + 'create full clone of the running VM', + sub { + $full_clone->{vmid} = $client->get('/cluster/nextid'); + my $upid = $client->post( + "/nodes/$node/qemu/$vmid/clone", + { newid => $full_clone->{vmid}, name => $full_clone->{name}, full => 1 }, + ); + $pve->task_ok($upid, "created full clone $full_clone->{vmid}") + or die "cloning VM $vmid failed\n"; + }, + sub { + $pve->destroy_guest('qemu', $full_clone->{vmid}) if defined($full_clone->{vmid}); + }, +)->step( + 'verify full clone', + sub { + $verify_clone->($full_clone, $vm_config->($vmid)); + }, +)->step( + 'stop VM', + sub { + $pve->stop_guest('qemu', $vmid); + }, +)->step( + 'convert VM to template', + sub { + my $upid = $client->post("/nodes/$node/qemu/$vmid/template", {}); + $pve->task_ok($upid, "converted VM $vmid to a template") + or die "converting VM $vmid to a template failed\n"; + + is($vm_config->($vmid)->{template}, 1, "VM $vmid is marked as template"); + + $upid = eval { $client->post("/nodes/$node/qemu/$vmid/status/start", {}) }; + my $status = defined($upid) ? $pve->wait_for_task($upid, 1) : "API error: $@"; + ok(!$pve->task_successful($status), 'starting the template is refused'); + }, +)->step( + 'create linked clone', + sub { + $linked_clone->{vmid} = $client->get('/cluster/nextid'); + my $upid = $client->post( + "/nodes/$node/qemu/$vmid/clone", + { newid => $linked_clone->{vmid}, name => $linked_clone->{name}, full => 0 }, + ); + $pve->task_ok($upid, "created linked clone $linked_clone->{vmid}") + or die "creating the linked clone failed\n"; + }, + sub { + $pve->destroy_guest('qemu', $linked_clone->{vmid}) if defined($linked_clone->{vmid}); + }, +)->step( + 'verify linked clone', + sub { + $verify_clone->($linked_clone, $vm_config->($vmid)); + }, +)->run(); + +done_testing(); diff --git a/tests/testcases.ron b/tests/testcases.ron index afbaa5b..e7f8917 100644 --- a/tests/testcases.ron +++ b/tests/testcases.ron @@ -187,6 +187,15 @@ TestcaseConfig( ], tag: "extended", ), + Testcase( + name: "pve_vm_snapshot_clone", + run: "./tests/pve_vm_snapshot_clone.pl", + timeout: 600, + instances: [ + "pve1", + ], + tag: "extended", + ), Testcase( name: "pve_ha_resource_config", run: "./tests/pve_ha_resource_config.pl", -- 2.47.3