public inbox for pve-devel@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 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




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