all lists on lists.proxmox.com
 help / color / mirror / Atom feed
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




      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 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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal