From: copystring <copystring@gmail.com>
To: pve-devel@lists.proxmox.com
Cc: copystring <copystring@gmail.com>
Subject: SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction
Date: Mon, 6 Jul 2026 21:39:32 +0200 [thread overview]
Message-ID: <20260706193934.279992-5-copystring@gmail.com> (raw)
In-Reply-To: <20260706193934.279992-1-copystring@gmail.com>
Exercise the replace_rootfs_from_oci API method with mocked storage, config locking and worker execution.
The tests cover the destructive confirmation guard, non-OCI archives, stopped-only and protected/template/HA/snapshot/pending preconditions, missing free unused slots, missing or unmanaged rootfs volumes, foreign-owned rootfs volumes, unsupported target storage, shrink requests, successful replacement with zero, one and multiple mpX mount points, update-oci-config=0, prepare failures, fork failures and the conservative cleanup path that must not delete a new rootfs after a config write attempt has started.
Signed-off-by: copystring <copystring@gmail.com>
---
src/test/api-oci-rootfs-replace-test.pm | 486 +++++++++++++++++++++++
src/test/run_oci_rootfs_replace_tests.pl | 2 +-
2 files changed, 487 insertions(+), 1 deletion(-)
create mode 100644 src/test/api-oci-rootfs-replace-test.pm
diff --git a/src/test/api-oci-rootfs-replace-test.pm b/src/test/api-oci-rootfs-replace-test.pm
new file mode 100644
index 0000000..5cf8520
--- /dev/null
+++ b/src/test/api-oci-rootfs-replace-test.pm
@@ -0,0 +1,486 @@
+package PVE::LXC::APITest;
+
+use strict;
+use warnings;
+
+use lib qw(..);
+
+use Storable qw(dclone);
+use Test::MockModule;
+use Test::More;
+
+use PVE::API2::LXC;
+
+my $node = 'pve';
+my $vmid = 900;
+my $archive = 'local:vztmpl/test-oci.tar';
+my $old_volid = 'local-lvm:vm-900-disk-0';
+my $new_volid = 'local-lvm:vm-900-disk-9';
+my $storage_cfg = {};
+
+my $base_conf = {
+ digest => 'test-digest',
+ rootfs => "$old_volid,size=2G,acl=1",
+ mp0 => 'local-lvm:vm-900-disk-1,mp=/data,size=1G',
+ entrypoint => '/usr/bin/old-entrypoint',
+ cmode => 'console',
+ env => "OLD=1\0PATH=/old",
+ ostype => 'debian',
+ arch => 'amd64',
+ lxc => [
+ ['lxc.idmap', 'u 0 100000 65536'],
+ ['lxc.init.cwd', '/old-cwd'],
+ ],
+};
+
+our (
+ $conf,
+ $fail_write_config,
+ $fail_create_oci_rootfs,
+ $fail_fork_worker,
+ $mock_archive_is_oci,
+ $mock_ha_managed,
+ $mock_old_volid_owner,
+ $mock_protected,
+ $mock_running,
+ $mock_storage_supports_rootdir,
+ $mock_template,
+ @archive_checks,
+ @storage_access_checks,
+ @storage_alloc_checks,
+ @vm_perm_checks,
+ @create_oci_rootfs_calls,
+ @write_config_calls,
+ @vdisk_free_calls,
+ @deactivate_calls,
+ @worker_calls,
+ $cfs_read_count,
+);
+
+{
+ package PVE::LXC::APITest::RPCEnv;
+
+ sub new {
+ return bless {}, shift;
+ }
+
+ sub get_user {
+ return 'root@pam';
+ }
+
+ sub check_vm_perm {
+ my ($self, $authuser, $vmid, $pool, $perms) = @_;
+ push @PVE::LXC::APITest::vm_perm_checks, [$authuser, $vmid, $pool, [@$perms]];
+ }
+
+ sub check {
+ my ($self, $authuser, $path, $perms) = @_;
+ push @PVE::LXC::APITest::storage_alloc_checks, [$authuser, $path, [@$perms]];
+ }
+
+ sub fork_worker {
+ my ($self, $type, $id, $authuser, $worker) = @_;
+ die "mocked fork_worker failure\n" if $PVE::LXC::APITest::fail_fork_worker;
+ push @PVE::LXC::APITest::worker_calls, [$type, $id, $authuser];
+ $worker->();
+ return 'UPID:pve:test:replace_rootfs_from_oci:root@pam:';
+ }
+}
+
+my $rpcenv = PVE::LXC::APITest::RPCEnv->new();
+
+my $rpcenv_module = Test::MockModule->new('PVE::RPCEnvironment');
+$rpcenv_module->mock(get => sub { return $rpcenv; });
+
+my $cluster_module = Test::MockModule->new('PVE::Cluster');
+$cluster_module->mock(
+ cfs_read_file => sub {
+ my ($file) = @_;
+ is($file, 'storage.cfg', 'reads storage.cfg');
+ $cfs_read_count++;
+ return $storage_cfg;
+ },
+);
+
+my $storage_module = Test::MockModule->new('PVE::Storage');
+$storage_module->mock(
+ check_volume_access => sub {
+ my ($rpcenv, $authuser, $cfg, $got_vmid, $volid, $content) = @_;
+ push @storage_access_checks, [$authuser, $got_vmid, $volid, $content];
+ },
+ parse_volname => sub {
+ my ($cfg, $volid) = @_;
+ return (undef, undef, $mock_old_volid_owner) if $volid eq $old_volid;
+ return (undef, undef, undef);
+ },
+ parse_volume_id => sub {
+ my ($volid) = @_;
+ my ($storeid, $name) = split(/:/, $volid, 2);
+ return ($storeid, $name);
+ },
+ storage_check_enabled => sub {
+ my ($cfg, $storage, $got_node) = @_;
+ is($got_node, $node, 'checks target storage on node');
+ return { content => { rootdir => $mock_storage_supports_rootdir ? 1 : 0 } };
+ },
+ activate_storage => sub { return undef; },
+ deactivate_volumes => sub {
+ my ($cfg, $volids) = @_;
+ push @deactivate_calls, [@$volids];
+ },
+ vdisk_free => sub {
+ my ($cfg, $volid) = @_;
+ push @vdisk_free_calls, $volid;
+ },
+);
+
+my $lxc_module = Test::MockModule->new('PVE::LXC');
+$lxc_module->mock(
+ check_running => sub { return $mock_running ? 1 : 0; },
+);
+
+my $ha_module = Test::MockModule->new('PVE::HA::Config');
+$ha_module->mock(vm_is_ha_managed => sub { return $mock_ha_managed ? 1 : 0; });
+
+my $config_module = Test::MockModule->new('PVE::LXC::Config');
+$config_module->mock(
+ lock_config => sub {
+ my ($class, $got_vmid, $code) = @_;
+ is($got_vmid, $vmid, 'locks expected CT config');
+ return $code->();
+ },
+ load_config => sub {
+ return dclone($conf);
+ },
+ check_protection => sub {
+ die "can't replace rootfs of CT $vmid - protection mode enabled\n" if $mock_protected;
+ return undef;
+ },
+ check_lock => sub { return undef; },
+ is_template => sub { return $mock_template ? 1 : 0; },
+ set_lock => sub {
+ my ($class, $got_vmid, $lock) = @_;
+ is($got_vmid, $vmid, 'sets lock on expected CT');
+ $conf->{lock} = $lock;
+ },
+ has_lock => sub {
+ my ($class, $got_conf, $lock) = @_;
+ return ($got_conf->{lock} // '') eq $lock;
+ },
+ write_config => sub {
+ my ($class, $got_vmid, $new_conf) = @_;
+ is($got_vmid, $vmid, 'writes expected CT config');
+ push @write_config_calls, dclone($new_conf);
+ die "mocked write_config failure\n" if $fail_write_config;
+ $conf = dclone($new_conf);
+ },
+ remove_lock => sub {
+ my ($class, $got_vmid, $lock) = @_;
+ is($got_vmid, $vmid, 'removes lock from expected CT');
+ delete $conf->{lock};
+ },
+);
+
+my $create_module = Test::MockModule->new('PVE::LXC::Create');
+$create_module->mock(
+ archive_is_oci_format => sub {
+ my ($cfg, $volid) = @_;
+ push @archive_checks, $volid;
+ return $mock_archive_is_oci ? 1 : 0;
+ },
+ create_oci_rootfs => sub {
+ my ($cfg, $got_vmid, $got_archive, $storage, $size, $got_conf) = @_;
+ push @create_oci_rootfs_calls,
+ [$got_vmid, $got_archive, $storage, $size, dclone($got_conf)];
+ die "mocked create_oci_rootfs failure\n" if $fail_create_oci_rootfs;
+ return (
+ "$new_volid,size=2G,acl=1",
+ $new_volid,
+ {
+ entrypoint => '/usr/bin/new-entrypoint',
+ env => "FOO=bar\0PATH=/usr/bin",
+ ostype => 'alpine',
+ arch => 'amd64',
+ lxc => [
+ ['lxc.idmap', 'u 0 100000 65536'],
+ ['lxc.init.cmd', '/usr/bin/new-entrypoint'],
+ ['lxc.signal.halt', 'SIGTERM'],
+ ],
+ },
+ );
+ },
+);
+
+sub reset_state {
+ $conf = dclone($base_conf);
+ $fail_write_config = 0;
+ $fail_create_oci_rootfs = 0;
+ $fail_fork_worker = 0;
+ $mock_archive_is_oci = 1;
+ $mock_ha_managed = 0;
+ $mock_old_volid_owner = $vmid;
+ $mock_protected = 0;
+ $mock_running = 0;
+ $mock_storage_supports_rootdir = 1;
+ $mock_template = 0;
+ @archive_checks = ();
+ @storage_access_checks = ();
+ @storage_alloc_checks = ();
+ @vm_perm_checks = ();
+ @create_oci_rootfs_calls = ();
+ @write_config_calls = ();
+ @vdisk_free_calls = ();
+ @deactivate_calls = ();
+ @worker_calls = ();
+ $cfs_read_count = 0;
+}
+
+sub call_replace_rootfs {
+ my ($extra) = @_;
+
+ my $info = PVE::API2::LXC->map_method_by_name('replace_rootfs_from_oci');
+ return PVE::API2::LXC->handle($info, {
+ node => $node,
+ vmid => $vmid,
+ ostemplate => $archive,
+ %{$extra // {}},
+ });
+}
+
+subtest 'missing confirmation aborts before storage access' => sub {
+ reset_state();
+
+ eval { call_replace_rootfs({}) };
+ like($@, qr/missing explicit rootfs replacement confirmation/, 'requires confirmation');
+ is($cfs_read_count, 0, 'does not read storage config without confirmation');
+ is_deeply(\@archive_checks, [], 'does not inspect archive without confirmation');
+ is_deeply($conf, $base_conf, 'config stays unchanged without confirmation');
+};
+
+sub assert_rejects_before_prepare {
+ my ($name, $setup, $pattern, $params) = @_;
+
+ subtest $name => sub {
+ reset_state();
+ $setup->();
+ my $expected_conf = dclone($conf);
+
+ eval { call_replace_rootfs($params // { 'confirm-rootfs-replace' => 1 }) };
+ like($@, $pattern, 'rejects request');
+ is_deeply($conf, $expected_conf, 'config stays unchanged');
+ is_deeply(\@create_oci_rootfs_calls, [], 'does not prepare new rootfs');
+ is_deeply(\@write_config_calls, [], 'does not write config');
+ is_deeply(\@vdisk_free_calls, [], 'does not delete volumes');
+ is_deeply(\@worker_calls, [], 'does not fork worker');
+ };
+}
+
+subtest 'non-OCI archive aborts before config lock' => sub {
+ reset_state();
+ $mock_archive_is_oci = 0;
+
+ eval { call_replace_rootfs({ 'confirm-rootfs-replace' => 1 }) };
+ like($@, qr/is not an OCI image archive/, 'rejects non-OCI archive');
+ is_deeply($conf, $base_conf, 'config stays unchanged');
+ is_deeply(\@vm_perm_checks, [], 'does not check option permissions');
+ is_deeply(\@create_oci_rootfs_calls, [], 'does not prepare new rootfs');
+ is_deeply(\@worker_calls, [], 'does not fork worker');
+};
+
+assert_rejects_before_prepare(
+ 'running container is rejected before preparing rootfs',
+ sub { $mock_running = 1; },
+ qr/cannot replace rootfs of a running container/,
+);
+
+assert_rejects_before_prepare(
+ 'protected container is rejected before preparing rootfs',
+ sub { $mock_protected = 1; },
+ qr/protection mode enabled/,
+);
+
+assert_rejects_before_prepare(
+ 'template container is rejected before preparing rootfs',
+ sub { $mock_template = 1; },
+ qr/cannot replace rootfs of a template container/,
+);
+
+assert_rejects_before_prepare(
+ 'HA-managed container is rejected before preparing rootfs',
+ sub { $mock_ha_managed = 1; },
+ qr/cannot replace rootfs of a HA-managed container/,
+);
+
+assert_rejects_before_prepare(
+ 'container with snapshots is rejected before preparing rootfs',
+ sub { $conf->{snapshots} = { before => {} }; },
+ qr/cannot replace rootfs while CT has snapshots/,
+);
+
+assert_rejects_before_prepare(
+ 'container with pending changes is rejected before preparing rootfs',
+ sub { $conf->{pending} = { rootfs => 'local-lvm:vm-900-disk-pending,size=2G' }; },
+ qr/cannot replace rootfs while CT has pending configuration changes/,
+);
+
+assert_rejects_before_prepare(
+ 'container without free unused volume slot is rejected before preparing rootfs',
+ sub {
+ $conf->{"unused$_"} = "local-lvm:vm-900-unused-$_" for 0..255;
+ },
+ qr/Too many unused volumes/,
+);
+
+assert_rejects_before_prepare(
+ 'container without rootfs is rejected before preparing rootfs',
+ sub { delete $conf->{rootfs}; },
+ qr/missing rootfs configuration/,
+);
+
+assert_rejects_before_prepare(
+ 'container with unmanaged rootfs is rejected before preparing rootfs',
+ sub { $conf->{rootfs} = '/var/lib/lxc/900/rootfs,size=2G'; },
+ qr/can only replace rootfs backed by a managed volume/,
+);
+
+assert_rejects_before_prepare(
+ 'rootfs owned by another container is rejected before preparing rootfs',
+ sub { $mock_old_volid_owner = $vmid + 1; },
+ qr/can't replace rootfs owned by another container/,
+);
+
+assert_rejects_before_prepare(
+ 'target storage without rootdir content is rejected before preparing rootfs',
+ sub { $mock_storage_supports_rootdir = 0; },
+ qr/does not support CT rootdirs/,
+);
+
+assert_rejects_before_prepare(
+ 'shrink request is rejected before preparing rootfs',
+ sub { },
+ qr/unable to shrink rootfs/,
+ { 'confirm-rootfs-replace' => 1, size => '1G' },
+);
+subtest 'successful replacement switches only rootfs and keeps old rootfs unused' => sub {
+ reset_state();
+
+ my $upid = call_replace_rootfs({ 'confirm-rootfs-replace' => 1 });
+
+ is($upid, 'UPID:pve:test:replace_rootfs_from_oci:root@pam:', 'returns worker UPID');
+ is($conf->{rootfs}, "$new_volid,size=2G,acl=1", 'switches active rootfs');
+ is($conf->{unused0}, $old_volid, 'keeps old rootfs as unused volume');
+ is($conf->{mp0}, $base_conf->{mp0}, 'keeps mp0 unchanged');
+ is($conf->{entrypoint}, '/usr/bin/new-entrypoint', 'applies OCI entrypoint');
+ is($conf->{env}, "FOO=bar\0PATH=/usr/bin", 'applies OCI environment');
+ ok(!exists($conf->{cmode}), 'removes old console mode absent from OCI config');
+ is($conf->{ostype}, 'alpine', 'applies detected ostype');
+ is($conf->{arch}, 'amd64', 'applies detected architecture');
+ is_deeply(
+ $conf->{lxc},
+ [
+ ['lxc.idmap', 'u 0 100000 65536'],
+ ['lxc.init.cmd', '/usr/bin/new-entrypoint'],
+ ['lxc.signal.halt', 'SIGTERM'],
+ ],
+ 'replaces OCI runtime lxc keys while keeping non-runtime lxc entries',
+ );
+ is_deeply(\@vdisk_free_calls, [], 'does not delete any volume after successful switch');
+ is_deeply(\@worker_calls, [['replace_rootfs_from_oci', $vmid, 'root@pam']],
+ 'runs replacement worker');
+ is_deeply(\@vm_perm_checks, [['root@pam', $vmid, undef, ['VM.Config.Options']]],
+ 'checks option permission when updating OCI config');
+};
+
+subtest 'successful replacement also works without separate mount points' => sub {
+ reset_state();
+ delete $conf->{mp0};
+
+ call_replace_rootfs({ 'confirm-rootfs-replace' => 1 });
+
+ is($conf->{rootfs}, "$new_volid,size=2G,acl=1", 'switches active rootfs');
+ is($conf->{unused0}, $old_volid, 'keeps old rootfs as unused volume');
+ ok(!exists($conf->{mp0}), 'does not add mount points');
+ is_deeply(\@vdisk_free_calls, [], 'does not delete any volume after successful switch');
+};
+
+subtest 'successful replacement preserves multiple separate mount points' => sub {
+ reset_state();
+ $conf->{mp1} = 'local-lvm:vm-900-disk-2,mp=/cache,size=1G';
+
+ call_replace_rootfs({ 'confirm-rootfs-replace' => 1 });
+
+ is($conf->{rootfs}, "$new_volid,size=2G,acl=1", 'switches active rootfs');
+ is($conf->{mp0}, $base_conf->{mp0}, 'keeps mp0 unchanged');
+ is($conf->{mp1}, 'local-lvm:vm-900-disk-2,mp=/cache,size=1G', 'keeps mp1 unchanged');
+ is($conf->{unused0}, $old_volid, 'keeps old rootfs as unused volume');
+ is_deeply(\@vdisk_free_calls, [], 'does not delete any volume after successful switch');
+};
+subtest 'replacement without OCI config update preserves existing runtime config' => sub {
+ reset_state();
+
+ call_replace_rootfs({
+ 'confirm-rootfs-replace' => 1,
+ 'update-oci-config' => 0,
+ });
+
+ is($conf->{rootfs}, "$new_volid,size=2G,acl=1", 'switches active rootfs');
+ is($conf->{unused0}, $old_volid, 'keeps old rootfs as unused volume');
+ is($conf->{mp0}, $base_conf->{mp0}, 'keeps mp0 unchanged');
+ is($conf->{entrypoint}, $base_conf->{entrypoint}, 'keeps old entrypoint');
+ is($conf->{cmode}, $base_conf->{cmode}, 'keeps old console mode');
+ is($conf->{env}, $base_conf->{env}, 'keeps old environment');
+ is($conf->{ostype}, $base_conf->{ostype}, 'keeps old ostype');
+ is($conf->{arch}, $base_conf->{arch}, 'keeps old architecture');
+ is_deeply($conf->{lxc}, $base_conf->{lxc}, 'keeps old lxc runtime entries');
+ is_deeply(\@vm_perm_checks, [], 'does not require VM.Config.Options');
+};
+
+subtest 'prepare failure inside worker leaves config unchanged' => sub {
+ reset_state();
+ $fail_create_oci_rootfs = 1;
+
+ eval { call_replace_rootfs({ 'confirm-rootfs-replace' => 1 }) };
+ like($@, qr/mocked create_oci_rootfs failure/, 'propagates prepare failure');
+ is_deeply($conf, $base_conf, 'config stays unchanged after lock cleanup');
+ is_deeply(\@create_oci_rootfs_calls, [[
+ $vmid,
+ $archive,
+ 'local-lvm',
+ 2 * 1024 * 1024 * 1024,
+ dclone({ %$base_conf, lock => 'disk' }),
+ ]], 'attempts isolated rootfs preparation once');
+ is_deeply(\@write_config_calls, [], 'does not write config');
+ is_deeply(\@vdisk_free_calls, [], 'does not delete volumes in API layer');
+ is_deeply(\@worker_calls, [['replace_rootfs_from_oci', $vmid, 'root@pam']],
+ 'worker ran and failed');
+};
+
+subtest 'fork_worker failure removes replacement lock' => sub {
+ reset_state();
+ $fail_fork_worker = 1;
+
+ eval { call_replace_rootfs({ 'confirm-rootfs-replace' => 1 }) };
+ like($@, qr/mocked fork_worker failure/, 'propagates fork_worker failure');
+ is_deeply($conf, $base_conf, 'config lock is removed after fork failure');
+ is_deeply(\@create_oci_rootfs_calls, [], 'does not prepare new rootfs');
+ is_deeply(\@write_config_calls, [], 'does not write config');
+ is_deeply(\@worker_calls, [], 'worker never starts');
+};
+subtest 'write_config failure after switch attempt does not delete new rootfs' => sub {
+ reset_state();
+ $fail_write_config = 1;
+
+ my @warnings;
+ {
+ local $SIG{__WARN__} = sub { push @warnings, @_ };
+ eval { call_replace_rootfs({ 'confirm-rootfs-replace' => 1 }) };
+ }
+ like($@, qr/mocked write_config failure/, 'propagates write_config failure');
+ like(join('', @warnings), qr/not removing possibly referenced new rootfs/,
+ 'warns that the new rootfs might already be referenced');
+ is_deeply(\@vdisk_free_calls, [],
+ 'does not delete new rootfs after config write attempt started');
+ is($conf->{rootfs}, $base_conf->{rootfs}, 'mocked failed write leaves active config unchanged');
+};
+
+done_testing();
\ No newline at end of file
diff --git a/src/test/run_oci_rootfs_replace_tests.pl b/src/test/run_oci_rootfs_replace_tests.pl
index a13ae8e..0f723bc 100644
--- a/src/test/run_oci_rootfs_replace_tests.pl
+++ b/src/test/run_oci_rootfs_replace_tests.pl
@@ -6,5 +6,5 @@ use warnings;
use TAP::Harness;
my $harness = TAP::Harness->new({ "verbosity" => -2 });
-my $res = $harness->runtests("oci-rootfs-replace-test.pm");
+my $res = $harness->runtests("oci-rootfs-replace-test.pm", "api-oci-rootfs-replace-test.pm");
exit -1 if $res->{failed};
--
2.55.0.windows.2
next prev parent reply other threads:[~2026-07-09 8:22 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-06 19:39 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH 1/4] lxc: create: add isolated OCI rootfs preparation copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH 3/4] pct: add OCI rootfs replacement command copystring
2026-07-06 19:39 ` copystring [this message]
2026-07-06 19:39 ` SPAM: [RFC PATCH pve-manager] lxc: add OCI rootfs replacement window copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH pve-docs] pct: document OCI rootfs replacement semantics copystring
-- strict thread matches above, loose matches on Subject: below --
2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction copystring
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260706193934.279992-5-copystring@gmail.com \
--to=copystring@gmail.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox