From: copystring <copystring@gmail.com>
To: pve-devel@lists.proxmox.com
Cc: copystring <copystring@gmail.com>
Subject: SPAM: [RFC PATCH v2 4/4] test: cover OCI rootfs replacement API transaction
Date: Sat, 18 Jul 2026 18:06:02 +0200 [thread overview]
Message-ID: <6edfd64dd6e4fedcf4400f529a19bb61c769242b.1784389742.git.copystring@gmail.com> (raw)
In-Reply-To: <cover.1784389742.git.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/Makefile | 4 +-
src/test/api-oci-rootfs-replace-test.pm | 486 +++++++++++++++++++++++
src/test/oci-rootfs-replace-test.pm | 250 ++++++++++++
src/test/run_oci_rootfs_replace_tests.pl | 10 +
4 files changed, 749 insertions(+), 1 deletion(-)
create mode 100644 src/test/api-oci-rootfs-replace-test.pm
create mode 100644 src/test/oci-rootfs-replace-test.pm
create mode 100755 src/test/run_oci_rootfs_replace_tests.pl
diff --git a/src/test/Makefile b/src/test/Makefile
index 91ae6ff..8da35ee 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -2,7 +2,7 @@ RUN_USERNS := lxc-usernsexec -m "u:0:`id -u`:1" -m "g:0:`id -g`:1" --
all: test
-test: test_setup test_snapshot test_bindmount test_idmap
+test: test_setup test_snapshot test_bindmount test_idmap test_oci_rootfs_replace
test_setup: run_setup_tests.pl
if test -e /run/lock/sbuild; then \
@@ -24,5 +24,7 @@ test_bindmount: bindmount_test.pl
test_idmap: run_idmap_tests.pl
./run_idmap_tests.pl
+test_oci_rootfs_replace: run_oci_rootfs_replace_tests.pl
+ ./run_oci_rootfs_replace_tests.pl
clean:
rm -rf tmprootfs
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/oci-rootfs-replace-test.pm b/src/test/oci-rootfs-replace-test.pm
new file mode 100644
index 0000000..998c619
--- /dev/null
+++ b/src/test/oci-rootfs-replace-test.pm
@@ -0,0 +1,250 @@
+package PVE::LXC::Test;
+
+use strict;
+use warnings;
+
+use lib qw(..);
+
+use File::Temp qw(tempdir);
+use Storable qw(dclone);
+use Test::MockModule;
+use Test::More;
+
+use PVE::LXC;
+use PVE::LXC::Config;
+use PVE::LXC::Create;
+use PVE::LXC::Setup;
+use PVE::Tools;
+
+my $storage_cfg = {};
+my $vmid = 900;
+my $archive = 'local:vztmpl/test-oci.tar';
+my $storage = 'local-lvm';
+my $size = 2 * 1024 * 1024 * 1024;
+my $new_volid = 'local-lvm:vm-900-disk-9';
+
+my $base_conf = {
+ rootfs => 'local-lvm:vm-900-disk-0,size=2G,acl=1,mountoptions=noatime,quota=1',
+ mp0 => 'local-lvm:vm-900-disk-1,mp=/data,size=1G',
+ mp1 => '/host/path,mp=/bind',
+ unprivileged => 1,
+ lxc => [
+ ['lxc.idmap', 'u 0 100000 65536'],
+ ['lxc.idmap', 'g 0 100000 65536'],
+ ['lxc.init.cwd', '/old-cwd'],
+ ],
+};
+
+my (
+ $fail_restore,
+ $fail_umount,
+ $create_disks_settings,
+ $create_disks_conf,
+ @destroy_disks_calls,
+ @mountpoint_mount_calls,
+ @restore_oci_calls,
+ @setup_calls,
+ @run_command_calls,
+);
+
+my $create_module = Test::MockModule->new('PVE::LXC::Create');
+$create_module->mock(
+ archive_is_oci_format => sub {
+ my ($cfg, $volid) = @_;
+ is($cfg, $storage_cfg, 'archive check uses storage config');
+ is($volid, $archive, 'archive check uses requested OCI archive');
+ return 1;
+ },
+ restore_oci_archive => sub {
+ my ($cfg, $volid, $rootdir, $conf) = @_;
+ push @restore_oci_calls, [$cfg, $volid, $rootdir, dclone($conf)];
+ die "mocked OCI extraction failure\n" if $fail_restore;
+
+ $conf->{entrypoint} = '/usr/bin/new-entrypoint';
+ $conf->{env} = "FOO=bar\0PATH=/usr/bin";
+ push $conf->{lxc}->@*, ['lxc.signal.halt', 'SIGUSR1'];
+ },
+);
+
+my $lxc_module = Test::MockModule->new('PVE::LXC');
+$lxc_module->mock(
+ create_disks => sub {
+ my ($cfg, $got_vmid, $settings, $conf) = @_;
+ is($cfg, $storage_cfg, 'create_disks uses storage config');
+ is($got_vmid, $vmid, 'create_disks uses vmid');
+ $create_disks_settings = dclone($settings);
+ $create_disks_conf = dclone($conf);
+ $conf->{rootfs} = 'local-lvm:vm-900-disk-9,size=2G,acl=1,mountoptions=noatime,quota=1';
+ return [$new_volid];
+ },
+ destroy_disks => sub {
+ push @destroy_disks_calls, dclone(\@_);
+ },
+ mount_all => sub {
+ die "mount_all must not be used for OCI rootfs replacement\n";
+ },
+ mountpoint_mount => sub {
+ my ($mountpoint, $rootdir, $cfg, $snapname, $root_uid, $root_gid) = @_;
+ push @mountpoint_mount_calls,
+ [dclone($mountpoint), $rootdir, $cfg, $snapname, $root_uid, $root_gid];
+ },
+ parse_id_maps => sub {
+ return ([], 100000, 100000);
+ },
+ run_unshared => sub {
+ my ($code) = @_;
+ return $code->();
+ },
+);
+
+my $setup_module = Test::MockModule->new('PVE::LXC::Setup');
+$setup_module->mock(
+ new => sub {
+ my ($class, $conf, $rootdir) = @_;
+ push @setup_calls, [$class, dclone($conf), $rootdir];
+ $conf->{ostype} = 'debian';
+ $conf->{arch} = 'amd64';
+ return bless {}, $class;
+ },
+);
+
+my $tools_module = Test::MockModule->new('PVE::Tools');
+$tools_module->mock(
+ run_command => sub {
+ my ($cmd, %param) = @_;
+ push @run_command_calls, dclone($cmd);
+ die "mocked umount failure\n" if $fail_umount;
+ },
+);
+
+sub reset_state {
+ $fail_restore = 0;
+ $fail_umount = 0;
+ $create_disks_settings = undef;
+ $create_disks_conf = undef;
+ @destroy_disks_calls = ();
+ @mountpoint_mount_calls = ();
+ @restore_oci_calls = ();
+ @setup_calls = ();
+ @run_command_calls = ();
+}
+
+sub run_create_oci_rootfs {
+ my ($conf, $rootdir_base) = @_;
+
+ return PVE::LXC::Create::create_oci_rootfs(
+ $storage_cfg,
+ $vmid,
+ $archive,
+ $storage,
+ $size,
+ $conf,
+ $rootdir_base,
+ );
+}
+
+subtest 'prepares only a new rootfs and preserves OCI-derived config' => sub {
+ reset_state();
+ my $tmpdir = tempdir(CLEANUP => 1);
+ my $conf = dclone($base_conf);
+ my $orig_conf = dclone($conf);
+
+ my ($new_rootfs_conf, $got_new_volid, $oci_conf) = run_create_oci_rootfs($conf, $tmpdir);
+
+ is($got_new_volid, $new_volid, 'returns new rootfs volid');
+ is($new_rootfs_conf, 'local-lvm:vm-900-disk-9,size=2G,acl=1,mountoptions=noatime,quota=1',
+ 'returns new rootfs config');
+ is_deeply($conf, $orig_conf, 'source CT config is not mutated while preparing rootfs');
+
+ my $requested_rootfs = PVE::LXC::Config->parse_volume('rootfs', $create_disks_settings->{rootfs});
+ is($requested_rootfs->{volume}, 'local-lvm:2', 'allocates new rootfs on target storage');
+ is($requested_rootfs->{acl}, 1, 'preserves rootfs acl option');
+ is($requested_rootfs->{quota}, 1, 'preserves rootfs quota option');
+ is($requested_rootfs->{mountoptions}, 'noatime', 'preserves rootfs mount options');
+
+ is_deeply(
+ $create_disks_conf->{lxc},
+ [
+ ['lxc.idmap', 'u 0 100000 65536'],
+ ['lxc.idmap', 'g 0 100000 65536'],
+ ],
+ 'copies only idmap lxc entries into the temporary OCI config',
+ );
+ ok(!exists($create_disks_conf->{mp0}), 'does not copy mp0 into temporary OCI config');
+ ok(!exists($create_disks_conf->{mp1}), 'does not copy mp1 into temporary OCI config');
+
+ is(scalar(@mountpoint_mount_calls), 1, 'mounts exactly one filesystem');
+ my ($mountpoint, $rootdir, $got_storage_cfg, $snapname, $root_uid, $root_gid) =
+ $mountpoint_mount_calls[0]->@*;
+ is($mountpoint->{volume}, $new_volid, 'mounts the newly allocated rootfs');
+ is($mountpoint->{mp}, '/', 'mounts it as root only');
+ is($rootdir, "$tmpdir/.oci-replace-rootfs", 'uses isolated temporary rootfs path');
+ is($got_storage_cfg, $storage_cfg, 'mount uses storage config');
+ is($snapname, undef, 'mount does not use snapshot');
+ is($root_uid, 100000, 'mount uses mapped root uid');
+ is($root_gid, 100000, 'mount uses mapped root gid');
+
+ is(scalar(@restore_oci_calls), 1, 'extracts OCI image once');
+ is($restore_oci_calls[0]->[2], "$tmpdir/.oci-replace-rootfs", 'extracts into new rootfs');
+ is(scalar(@setup_calls), 1, 'detects OS and architecture on the new rootfs');
+ is($setup_calls[0]->[2], "$tmpdir/.oci-replace-rootfs", 'runs setup detection on new rootfs');
+
+ is($oci_conf->{entrypoint}, '/usr/bin/new-entrypoint', 'returns OCI entrypoint');
+ is($oci_conf->{env}, "FOO=bar\0PATH=/usr/bin", 'returns OCI environment');
+ is($oci_conf->{ostype}, 'debian', 'returns detected ostype');
+ is($oci_conf->{arch}, 'amd64', 'returns detected architecture');
+ is_deeply(
+ $oci_conf->{lxc},
+ [
+ ['lxc.idmap', 'u 0 100000 65536'],
+ ['lxc.idmap', 'g 0 100000 65536'],
+ ['lxc.signal.halt', 'SIGUSR1'],
+ ],
+ 'returns idmap plus OCI runtime lxc entries',
+ );
+
+ is_deeply(\@destroy_disks_calls, [], 'does not destroy disks after successful prepare');
+ is_deeply(\@run_command_calls, [['/bin/umount', "$tmpdir/.oci-replace-rootfs"]],
+ 'unmounts the temporary rootfs');
+ ok(!-e "$tmpdir/.oci-replace-rootfs", 'removes temporary rootfs directory');
+};
+
+subtest 'destroys newly allocated rootfs if OCI extraction fails' => sub {
+ reset_state();
+ my $tmpdir = tempdir(CLEANUP => 1);
+ my $conf = dclone($base_conf);
+ my $orig_conf = dclone($conf);
+ $fail_restore = 1;
+
+ eval { run_create_oci_rootfs($conf, $tmpdir) };
+ like($@, qr/mocked OCI extraction failure/, 'propagates OCI extraction failure');
+ is_deeply($conf, $orig_conf, 'source CT config stays unchanged on extraction failure');
+ is(scalar(@mountpoint_mount_calls), 1, 'mounted the new rootfs before extraction failure');
+ is_deeply(\@run_command_calls, [['/bin/umount', "$tmpdir/.oci-replace-rootfs"]],
+ 'unmounts temporary rootfs after extraction failure');
+ is_deeply(
+ \@destroy_disks_calls,
+ [[$storage_cfg, [$new_volid]]],
+ 'destroys the newly allocated rootfs on extraction failure',
+ );
+ ok(!-e "$tmpdir/.oci-replace-rootfs", 'removes temporary rootfs directory after failure');
+};
+
+subtest 'destroys newly allocated rootfs if cleanup after successful extraction fails' => sub {
+ reset_state();
+ my $tmpdir = tempdir(CLEANUP => 1);
+ my $conf = dclone($base_conf);
+ my $orig_conf = dclone($conf);
+ $fail_umount = 1;
+
+ eval { run_create_oci_rootfs($conf, $tmpdir) };
+ like($@, qr/can't unmount temporary rootfs path/, 'propagates cleanup failure');
+ is_deeply($conf, $orig_conf, 'source CT config stays unchanged on cleanup failure');
+ is_deeply(
+ \@destroy_disks_calls,
+ [[$storage_cfg, [$new_volid]]],
+ 'destroys the newly allocated rootfs on cleanup failure',
+ );
+};
+
+done_testing();
diff --git a/src/test/run_oci_rootfs_replace_tests.pl b/src/test/run_oci_rootfs_replace_tests.pl
new file mode 100755
index 0000000..0f723bc
--- /dev/null
+++ b/src/test/run_oci_rootfs_replace_tests.pl
@@ -0,0 +1,10 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use TAP::Harness;
+
+my $harness = TAP::Harness->new({ "verbosity" => -2 });
+my $res = $harness->runtests("oci-rootfs-replace-test.pm", "api-oci-rootfs-replace-test.pm");
+exit -1 if $res->{failed};
--
2.55.0.windows.3
prev parent reply other threads:[~2026-07-20 11:29 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
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 1/4] lxc: create: add isolated OCI rootfs preparation copystring
2026-07-15 12:02 ` Filip Schauer
2026-07-06 19:40 ` SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH 3/4] pct: add OCI rootfs replacement command copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH pve-manager] lxc: add OCI rootfs replacement window copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH pve-docs] pct: document OCI rootfs replacement semantics copystring
2026-07-14 13:23 ` [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement Filip Schauer
2026-07-18 16:05 ` SPAM: [RFC PATCH v2 " copystring
2026-07-18 16:05 ` SPAM: [RFC PATCH v2 1/4] lxc: create: add isolated OCI rootfs preparation copystring
2026-07-18 16:06 ` SPAM: [RFC PATCH v2 2/4] api: lxc: add OCI rootfs replacement endpoint copystring
2026-07-18 16:06 ` SPAM: [RFC PATCH v2 3/4] pct: add OCI rootfs replacement command copystring
2026-07-18 16:06 ` copystring [this message]
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=6edfd64dd6e4fedcf4400f529a19bb61c769242b.1784389742.git.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