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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.