public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint
  2026-07-06 19:39 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
@ 2026-07-06 19:39 ` copystring
  0 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:39 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

Add a conservative offline API endpoint that prepares a new rootfs from an OCI archive, switches rootfs under config lock, and keeps the previous rootfs as an unused volume for rollback.

The endpoint refuses running, protected, template, HA-managed, snapshot-bearing, and pending-change containers. It requires an explicit confirmation flag and keeps mpX mount points unchanged.

Signed-off-by: copystring <copystring@gmail.com>
---
 src/PVE/API2/LXC.pm | 300 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 300 insertions(+)

diff --git a/src/PVE/API2/LXC.pm b/src/PVE/API2/LXC.pm
index 88067dd..82fd59a 100644
--- a/src/PVE/API2/LXC.pm
+++ b/src/PVE/API2/LXC.pm
@@ -704,6 +704,7 @@ __PACKAGE__->register_method({
             { subdir => 'firewall' },
             { subdir => 'snapshot' },
             { subdir => 'resize' },
+            { subdir => 'replace_rootfs_from_oci' },
             { subdir => 'interfaces' },
         ];
 
@@ -2476,6 +2477,305 @@ __PACKAGE__->register_method({
     },
 });
 
+my $oci_runtime_lxc_keys = {
+    map { $_ => 1 } qw(
+        lxc.init.cmd
+        lxc.init.uid
+        lxc.init.gid
+        lxc.init.groups
+        lxc.init.cwd
+        lxc.signal.halt
+    )
+};
+
+my sub assert_free_unused_volume_slot {
+    my ($conf) = @_;
+
+    my $probe = { %$conf };
+    PVE::LXC::Config->add_unused_volume($probe, '__pve_oci_rootfs_replace_probe__');
+}
+
+my sub apply_oci_runtime_config {
+    my ($conf, $oci_conf) = @_;
+
+    for my $opt (qw(entrypoint cmode env ostype arch)) {
+        if (exists($oci_conf->{$opt})) {
+            $conf->{$opt} = $oci_conf->{$opt};
+        } else {
+            delete $conf->{$opt};
+        }
+    }
+
+    my $lxc = [grep { !$oci_runtime_lxc_keys->{ $_->[0] } } @{ $conf->{lxc} // [] }];
+    push @$lxc, grep { $oci_runtime_lxc_keys->{ $_->[0] } } @{ $oci_conf->{lxc} // [] };
+    $conf->{lxc} = $lxc;
+}
+
+__PACKAGE__->register_method({
+    name => 'replace_rootfs_from_oci',
+    path => '{vmid}/replace_rootfs_from_oci',
+    method => 'POST',
+    protected => 1,
+    proxyto => 'node',
+    description => "Replace a container rootfs with the contents of an OCI image archive. "
+        . "The previous rootfs is kept as an unused volume, while separate mpX mount points "
+        . "remain configured.",
+    permissions => {
+        description => "You need 'VM.Config.Disk' permissions on /vms/{vmid} and "
+            . "'Datastore.AllocateSpace' permissions on the target storage. Updating OCI "
+            . "runtime configuration additionally requires 'VM.Config.Options'.",
+        check => ['perm', '/vms/{vmid}', ['VM.Config.Disk']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            vmid =>
+                get_standard_option('pve-vmid', { completion => \&PVE::LXC::complete_ctid }),
+            ostemplate => {
+                description => "The OCI image archive used to replace the rootfs.",
+                type => 'string',
+                maxLength => 255,
+                completion => \&PVE::LXC::complete_os_templates,
+            },
+            storage => get_standard_option(
+                'pve-storage-id',
+                {
+                    description => "Target storage for the new rootfs. Defaults to the current "
+                        . "rootfs storage.",
+                    completion => \&PVE::LXC::complete_storage,
+                    optional => 1,
+                },
+            ),
+            size => {
+                type => 'string',
+                pattern => '\d+(\.\d+)?[KMGT]?',
+                description => "The size of the new rootfs. Defaults to the current rootfs "
+                    . "size. Shrinking is not supported.",
+                optional => 1,
+            },
+            digest => {
+                type => 'string',
+                description => 'Prevent changes if current configuration file has different SHA1 '
+                    . 'digest. This can be used to prevent concurrent modifications.',
+                maxLength => 40,
+                optional => 1,
+            },
+            'confirm-rootfs-replace' => {
+                type => 'boolean',
+                description => "Required confirmation that the current rootfs will be replaced. "
+                    . "Separate mpX mount points are preserved, but data stored only on / will "
+                    . "not be part of the newly active rootfs.",
+                optional => 1,
+                default => 0,
+            },
+            'update-oci-config' => {
+                type => 'boolean',
+                description => "Update OCI-derived runtime configuration such as entrypoint, "
+                    . "environment, OS type, architecture, working directory and stop signal.",
+                optional => 1,
+                default => 1,
+            },
+        },
+    },
+    returns => {
+        type => 'string',
+        description => "the task ID.",
+    },
+    code => sub {
+        my ($param) = @_;
+
+        my $rpcenv = PVE::RPCEnvironment::get();
+        my $authuser = $rpcenv->get_user();
+
+        my $node = extract_param($param, 'node');
+        my $vmid = extract_param($param, 'vmid');
+        my $archive = extract_param($param, 'ostemplate');
+        my $storage = extract_param($param, 'storage');
+        my $sizestr = extract_param($param, 'size');
+        my $digest = extract_param($param, 'digest');
+        my $confirm_rootfs_replace = extract_param($param, 'confirm-rootfs-replace') // 0;
+        my $update_oci_config = extract_param($param, 'update-oci-config') // 1;
+
+        die "missing explicit rootfs replacement confirmation\n"
+            if !$confirm_rootfs_replace;
+
+        my $storage_cfg = cfs_read_file("storage.cfg");
+
+        PVE::Storage::check_volume_access(
+            $rpcenv, $authuser, $storage_cfg, $vmid, $archive, 'vztmpl',
+        );
+        die "archive '$archive' is not an OCI image archive\n"
+            if !PVE::LXC::Create::archive_is_oci_format($storage_cfg, $archive);
+
+        $rpcenv->check_vm_perm($authuser, $vmid, undef, ['VM.Config.Options'])
+            if $update_oci_config;
+
+        my $lockname = 'disk';
+        my ($old_rootfs_conf, $old_rootfs, $old_volid, $target_size);
+
+        my $load_and_lock = sub {
+            PVE::LXC::Config->lock_config(
+                $vmid,
+                sub {
+                    my $conf = PVE::LXC::Config->load_config($vmid);
+                    PVE::LXC::Config->check_protection($conf, "can't replace rootfs of CT $vmid");
+                    PVE::LXC::Config->check_lock($conf);
+                    PVE::Tools::assert_if_modified($digest, $conf->{digest});
+
+                    die "cannot replace rootfs of a running container\n"
+                        if PVE::LXC::check_running($vmid);
+                    die "cannot replace rootfs of a template container\n"
+                        if PVE::LXC::Config->is_template($conf);
+                    die "cannot replace rootfs of a HA-managed container\n"
+                        if PVE::HA::Config::vm_is_ha_managed($vmid);
+                    die "cannot replace rootfs while CT has snapshots\n"
+                        if $conf->{snapshots} && scalar(keys $conf->{snapshots}->%*);
+                    die "cannot replace rootfs while CT has pending configuration changes\n"
+                        if $conf->{pending} && scalar(keys $conf->{pending}->%*);
+
+                    assert_free_unused_volume_slot($conf);
+
+                    $old_rootfs_conf = $conf->{rootfs}
+                        or die "missing rootfs configuration\n";
+                    $old_rootfs = PVE::LXC::Config->parse_volume('rootfs', $old_rootfs_conf);
+                    die "can only replace rootfs backed by a managed volume\n"
+                        if $old_rootfs->{type} ne 'volume';
+                    $old_volid = $old_rootfs->{volume};
+
+                    my (undef, undef, $owner) =
+                        PVE::Storage::parse_volname($storage_cfg, $old_volid);
+                    die "can't replace rootfs owned by another container ($owner)\n"
+                        if defined($owner) && $owner != $vmid;
+
+                    my ($current_storage) = PVE::Storage::parse_volume_id($old_volid);
+                    $storage //= $current_storage;
+
+                    my $scfg = PVE::Storage::storage_check_enabled($storage_cfg, $storage, $node);
+                    die "storage '$storage' does not support CT rootdirs\n"
+                        if !$scfg->{content}->{rootdir};
+                    $rpcenv->check($authuser, "/storage/$storage", ['Datastore.AllocateSpace']);
+                    PVE::Storage::activate_storage($storage_cfg, $storage);
+
+                    my $old_size = $old_rootfs->{size};
+                    if (!defined($old_size)) {
+                        eval { PVE::Storage::activate_volumes($storage_cfg, [$old_volid]); };
+                        die $@ if $@;
+                        eval {
+                            $old_size = PVE::Storage::volume_size_info(
+                                $storage_cfg, $old_volid, 5,
+                            );
+                        };
+                        my $err = $@;
+                        eval { PVE::Storage::deactivate_volumes($storage_cfg, [$old_volid]); };
+                        warn $@ if $@;
+                        die $err if $err;
+                    }
+                    die "unable to detect current rootfs size\n" if !defined($old_size);
+
+                    if (defined($sizestr)) {
+                        $target_size = PVE::JSONSchema::parse_size($sizestr);
+                        die "invalid size string\n" if !defined($target_size);
+                        die "unable to shrink rootfs\n" if $target_size < $old_size;
+                    } else {
+                        $target_size = $old_size;
+                    }
+
+                    PVE::LXC::Config->set_lock($vmid, $lockname);
+                },
+            );
+        };
+
+        my $worker = sub {
+            eval {
+                PVE::Cluster::log_msg(
+                    'info',
+                    $authuser,
+                    "replace rootfs of CT $vmid from OCI image '$archive'",
+                );
+
+                print "replace rootfs of CT $vmid from OCI image '$archive'\n";
+                print "old rootfs will be kept as an unused volume for rollback\n";
+                print "separate mount points mp0, mp1, ... are preserved\n";
+                print "data stored only on the old rootfs will not be present in the new rootfs\n";
+
+                my $conf = PVE::LXC::Config->load_config($vmid);
+                my $locked_digest = $conf->{digest};
+                my $locked_rootfs_conf = $conf->{rootfs};
+
+                my ($new_rootfs_conf, $new_volid, $oci_conf);
+                my $config_update_started = 0;
+                my $switched = 0;
+
+                eval {
+                    ($new_rootfs_conf, $new_volid, $oci_conf) =
+                        PVE::LXC::Create::create_oci_rootfs(
+                            $storage_cfg, $vmid, $archive, $storage, $target_size, $conf,
+                        );
+
+                    PVE::LXC::Config->lock_config(
+                        $vmid,
+                        sub {
+                            my $current_conf = PVE::LXC::Config->load_config($vmid);
+                            die "lost rootfs replacement lock\n"
+                                if !PVE::LXC::Config->has_lock($current_conf, $lockname);
+                            PVE::Tools::assert_if_modified(
+                                $locked_digest, $current_conf->{digest},
+                            );
+                            die "rootfs changed while replacement was running\n"
+                                if $current_conf->{rootfs} ne $locked_rootfs_conf;
+
+                            $current_conf->{rootfs} = $new_rootfs_conf;
+                            my $unused =
+                                PVE::LXC::Config->add_unused_volume($current_conf, $old_volid);
+                            die "old rootfs is already referenced as unused volume\n"
+                                if !defined($unused);
+
+                            apply_oci_runtime_config($current_conf, $oci_conf)
+                                if $update_oci_config;
+
+                            $config_update_started = 1;
+                            PVE::LXC::Config->write_config($vmid, $current_conf);
+                            $switched = 1;
+                        },
+                    );
+                };
+                if (my $err = $@) {
+                    if (defined($new_volid) && !$config_update_started) {
+                        eval { PVE::Storage::vdisk_free($storage_cfg, $new_volid); };
+                        warn $@ if $@;
+                    } elsif (defined($new_volid) && !$switched) {
+                        warn "not removing possibly referenced new rootfs '$new_volid'\n";
+                    }
+                    die $err;
+                }
+
+                eval { PVE::Storage::deactivate_volumes($storage_cfg, [$new_volid]); };
+                warn $@ if $@;
+                eval { PVE::Storage::deactivate_volumes($storage_cfg, [$old_volid]); };
+                warn $@ if $@;
+            };
+            my $err = $@;
+            eval { PVE::LXC::Config->remove_lock($vmid, $lockname) };
+            warn $@ if $@;
+            die $err if $err;
+        };
+
+        $load_and_lock->();
+
+        my $upid = eval {
+            $rpcenv->fork_worker('replace_rootfs_from_oci', $vmid, $authuser, $worker)
+        };
+        if (my $err = $@) {
+            eval { PVE::LXC::Config->remove_lock($vmid, $lockname) };
+            warn $@ if $@;
+            die $err;
+        }
+
+        return $upid;
+    },
+});
+
 __PACKAGE__->register_method({
     name => 'move_volume',
     path => '{vmid}/move_volume',
-- 
2.55.0.windows.2




^ permalink raw reply related	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement
@ 2026-07-06 19:40 copystring
  2026-07-06 19:40 ` SPAM: [RFC PATCH 1/4] lxc: create: add isolated OCI rootfs preparation copystring
                   ` (5 more replies)
  0 siblings, 6 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

This RFC adds an explicit rootfs replacement path for stopped LXC containers
created from OCI images. The intent is to support the common "refresh the OCI
image" workflow without pretending that data stored inside the old rootfs can be
merged safely.

The safety model is intentionally conservative:

* require an explicit confirmation parameter;
* only operate on stopped, non-template, non-HA, unprotected CTs without
  snapshots or pending config changes;
* only replace the active rootfs volume, while preserving mpX mount points;
* keep the previous rootfs as an unusedX volume instead of deleting it;
* fail if no unusedX slot is available;
* never remove the newly allocated rootfs once config writing has started;
* do not use overlayfs or path heuristics such as /config or /data detection;
* reject shrink-like requests by requiring the target size to be at least the
  current rootfs size.

This means data that only exists on / becomes data on the old unused volume, not
on the new active rootfs. Users are expected to keep persistent application data
on separate mount points or to have a backup before replacing the rootfs.

By default, the endpoint also updates OCI-derived runtime config such as
entrypoint, environment, ostype, arch and OCI-generated lxc keys. A caller can
set update-oci-config=0 to preserve the existing runtime config and only switch
the rootfs.

The pve-manager GUI and pve-docs changes are kept as separate repository series,
but they are part of the same proposed workflow: the UI exposes the destructive
operation behind a confirmation dialog, and the docs describe exactly which data
is preserved and which is not.

Tested on a disposable PVE 9.2.2 VM:

* make -C src check
* make -C /root/proxmox-dev/pve-container clean deb
* API tests cover 24 rootfs replacement subtests, including confirmation,
  non-OCI archives, stopped-only and protected/template/HA/snapshot/pending
  guards, no free unused slot, missing/unmanaged/foreign-owned rootfs, target
  storage without rootdir content, shrink requests, zero/one/multiple mpX mount
  point preservation, update-oci-config=0, prepare failures, fork failures and
  write_config failure handling without deleting the new rootfs.
copystring (4):
  lxc: create: add isolated OCI rootfs preparation
  api: lxc: add OCI rootfs replacement endpoint
  pct: add OCI rootfs replacement command
  test: cover OCI rootfs replacement API transaction

 src/PVE/API2/LXC.pm                      | 300 ++++++++++++++
 src/PVE/CLI/pct.pm                       |   8 +
 src/PVE/LXC/Create.pm                    |  95 +++++
 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 +
 7 files changed, 1152 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 100644 src/test/run_oci_rootfs_replace_tests.pl

-- 
2.55.0.windows.2




^ permalink raw reply	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH 1/4] lxc: create: add isolated OCI rootfs preparation
  2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
@ 2026-07-06 19:40 ` copystring
  2026-07-06 19:40 ` SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint copystring
                   ` (4 subsequent siblings)
  5 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

Prepare a new rootfs volume from an OCI archive without mounting any existing mpX mount points. This keeps the active container config untouched until a later API layer can switch rootfs atomically.

The helper preserves rootfs options, carries over only idmap-related LXC entries into the temporary extraction config, returns OCI-derived runtime metadata from the unshared worker, and tears down the new volume on prepare or cleanup failure.

Signed-off-by: copystring <copystring@gmail.com>
---
 src/PVE/LXC/Create.pm                    |  95 +++++++++
 src/test/Makefile                        |   4 +-
 src/test/oci-rootfs-replace-test.pm      | 250 +++++++++++++++++++++++
 src/test/run_oci_rootfs_replace_tests.pl |  10 +
 4 files changed, 358 insertions(+), 1 deletion(-)
 create mode 100644 src/test/oci-rootfs-replace-test.pm
 create mode 100644 src/test/run_oci_rootfs_replace_tests.pl

diff --git a/src/PVE/LXC/Create.pm b/src/PVE/LXC/Create.pm
index 69065b9..f809afb 100644
--- a/src/PVE/LXC/Create.pm
+++ b/src/PVE/LXC/Create.pm
@@ -783,6 +783,101 @@ sub restore_oci_archive {
     return $conf; # it's a reference anyway, so return mostly for convenience.
 }
 
+my sub oci_rootfs_size_to_gb {
+    my ($size) = @_;
+
+    my $size_mib = int(($size + 1024 * 1024 - 1) / (1024 * 1024));
+    my $size_gb = sprintf('%.6f', $size_mib / 1024);
+    $size_gb =~ s/0+$//;
+    $size_gb =~ s/\.$//;
+
+    return $size_gb;
+}
+
+my sub oci_config_lxc_copy {
+    my ($conf) = @_;
+
+    return [map { [@$_] } grep { $_->[0] eq 'lxc.idmap' } @{ $conf->{lxc} // [] }];
+}
+
+sub create_oci_rootfs {
+    my ($storage_cfg, $vmid, $archive, $storage, $size, $conf, $rootdir_base) = @_;
+
+    die "archive '$archive' is not an OCI image archive\n"
+        if !archive_is_oci_format($storage_cfg, $archive);
+
+    my $size_gb = oci_rootfs_size_to_gb($size);
+    my $rootfs = PVE::LXC::Config->parse_volume('rootfs', $conf->{rootfs});
+    my $new_rootfs_config = {
+        %$rootfs,
+        volume => "$storage:$size_gb",
+    };
+    delete $new_rootfs_config->{type};
+    delete $new_rootfs_config->{size};
+
+    my $settings = {
+        rootfs => PVE::LXC::Config->print_ct_mountpoint($new_rootfs_config, 1),
+    };
+    my $new_conf = {
+        lxc => oci_config_lxc_copy($conf),
+    };
+    $new_conf->{unprivileged} = $conf->{unprivileged} if exists($conf->{unprivileged});
+
+    $rootdir_base //= "/var/lib/lxc/$vmid";
+    File::Path::make_path($rootdir_base);
+    my $rootdir = "$rootdir_base/.oci-replace-rootfs";
+
+    my $vollist = PVE::LXC::create_disks($storage_cfg, $vmid, $settings, $new_conf);
+    my $new_rootfs = PVE::LXC::Config->parse_volume('rootfs', $new_conf->{rootfs});
+    my $new_volid = $new_rootfs->{volume};
+
+    eval {
+        my (undef, $root_uid, $root_gid) = PVE::LXC::parse_id_maps($conf);
+
+        $new_conf = PVE::LXC::run_unshared(sub {
+            die "temporary OCI rootfs path '$rootdir' already exists\n" if -e $rootdir;
+
+            my @mounted;
+            eval {
+                mkdir $rootdir
+                    or die "failed to create temporary OCI rootfs path '$rootdir': $!\n";
+
+                my $mountpoint = { %$new_rootfs, mp => '/', ro => 0 };
+                PVE::LXC::mountpoint_mount(
+                    $mountpoint, $rootdir, $storage_cfg, undef, $root_uid, $root_gid,
+                );
+                push @mounted, $rootdir;
+
+                restore_oci_archive($storage_cfg, $archive, $rootdir, $new_conf);
+                PVE::LXC::Setup->new($new_conf, $rootdir); # detect OS and architecture
+            };
+            my $err = $@;
+            my $cleanup_err = '';
+
+            foreach my $mount (reverse @mounted) {
+                eval {
+                    PVE::Tools::run_command(['/bin/umount', $mount], errfunc => sub { });
+                };
+                $cleanup_err .= "can't unmount temporary OCI rootfs path '$mount': $@\n" if $@;
+            }
+
+            $cleanup_err .= "failed to remove temporary OCI rootfs path '$rootdir': $!\n"
+                if -d $rootdir && !rmdir $rootdir;
+
+            die $err if $err;
+            die $cleanup_err if $cleanup_err;
+
+            return $new_conf;
+        });
+    };
+    if (my $err = $@) {
+        PVE::LXC::destroy_disks($storage_cfg, $vollist);
+        die $err;
+    }
+
+    return ($new_conf->{rootfs}, $new_volid, $new_conf);
+}
+
 sub resolve_oci_user {
     my ($userstr, $rootdir) = @_;
 
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/oci-rootfs-replace-test.pm b/src/test/oci-rootfs-replace-test.pm
new file mode 100644
index 0000000..88f8485
--- /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 OCI 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 100644
index 0000000..a13ae8e
--- /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");
+exit -1 if $res->{failed};
-- 
2.55.0.windows.2




^ permalink raw reply related	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint
  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-06 19:40 ` copystring
  2026-07-06 19:40 ` SPAM: [RFC PATCH 3/4] pct: add OCI rootfs replacement command copystring
                   ` (3 subsequent siblings)
  5 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

Add a conservative offline API endpoint that prepares a new rootfs from an OCI archive, switches rootfs under config lock, and keeps the previous rootfs as an unused volume for rollback.

The endpoint refuses running, protected, template, HA-managed, snapshot-bearing, and pending-change containers. It requires an explicit confirmation flag and keeps mpX mount points unchanged.

Signed-off-by: copystring <copystring@gmail.com>
---
 src/PVE/API2/LXC.pm | 300 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 300 insertions(+)

diff --git a/src/PVE/API2/LXC.pm b/src/PVE/API2/LXC.pm
index 88067dd..82fd59a 100644
--- a/src/PVE/API2/LXC.pm
+++ b/src/PVE/API2/LXC.pm
@@ -704,6 +704,7 @@ __PACKAGE__->register_method({
             { subdir => 'firewall' },
             { subdir => 'snapshot' },
             { subdir => 'resize' },
+            { subdir => 'replace_rootfs_from_oci' },
             { subdir => 'interfaces' },
         ];
 
@@ -2476,6 +2477,305 @@ __PACKAGE__->register_method({
     },
 });
 
+my $oci_runtime_lxc_keys = {
+    map { $_ => 1 } qw(
+        lxc.init.cmd
+        lxc.init.uid
+        lxc.init.gid
+        lxc.init.groups
+        lxc.init.cwd
+        lxc.signal.halt
+    )
+};
+
+my sub assert_free_unused_volume_slot {
+    my ($conf) = @_;
+
+    my $probe = { %$conf };
+    PVE::LXC::Config->add_unused_volume($probe, '__pve_oci_rootfs_replace_probe__');
+}
+
+my sub apply_oci_runtime_config {
+    my ($conf, $oci_conf) = @_;
+
+    for my $opt (qw(entrypoint cmode env ostype arch)) {
+        if (exists($oci_conf->{$opt})) {
+            $conf->{$opt} = $oci_conf->{$opt};
+        } else {
+            delete $conf->{$opt};
+        }
+    }
+
+    my $lxc = [grep { !$oci_runtime_lxc_keys->{ $_->[0] } } @{ $conf->{lxc} // [] }];
+    push @$lxc, grep { $oci_runtime_lxc_keys->{ $_->[0] } } @{ $oci_conf->{lxc} // [] };
+    $conf->{lxc} = $lxc;
+}
+
+__PACKAGE__->register_method({
+    name => 'replace_rootfs_from_oci',
+    path => '{vmid}/replace_rootfs_from_oci',
+    method => 'POST',
+    protected => 1,
+    proxyto => 'node',
+    description => "Replace a container rootfs with the contents of an OCI image archive. "
+        . "The previous rootfs is kept as an unused volume, while separate mpX mount points "
+        . "remain configured.",
+    permissions => {
+        description => "You need 'VM.Config.Disk' permissions on /vms/{vmid} and "
+            . "'Datastore.AllocateSpace' permissions on the target storage. Updating OCI "
+            . "runtime configuration additionally requires 'VM.Config.Options'.",
+        check => ['perm', '/vms/{vmid}', ['VM.Config.Disk']],
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            vmid =>
+                get_standard_option('pve-vmid', { completion => \&PVE::LXC::complete_ctid }),
+            ostemplate => {
+                description => "The OCI image archive used to replace the rootfs.",
+                type => 'string',
+                maxLength => 255,
+                completion => \&PVE::LXC::complete_os_templates,
+            },
+            storage => get_standard_option(
+                'pve-storage-id',
+                {
+                    description => "Target storage for the new rootfs. Defaults to the current "
+                        . "rootfs storage.",
+                    completion => \&PVE::LXC::complete_storage,
+                    optional => 1,
+                },
+            ),
+            size => {
+                type => 'string',
+                pattern => '\d+(\.\d+)?[KMGT]?',
+                description => "The size of the new rootfs. Defaults to the current rootfs "
+                    . "size. Shrinking is not supported.",
+                optional => 1,
+            },
+            digest => {
+                type => 'string',
+                description => 'Prevent changes if current configuration file has different SHA1 '
+                    . 'digest. This can be used to prevent concurrent modifications.',
+                maxLength => 40,
+                optional => 1,
+            },
+            'confirm-rootfs-replace' => {
+                type => 'boolean',
+                description => "Required confirmation that the current rootfs will be replaced. "
+                    . "Separate mpX mount points are preserved, but data stored only on / will "
+                    . "not be part of the newly active rootfs.",
+                optional => 1,
+                default => 0,
+            },
+            'update-oci-config' => {
+                type => 'boolean',
+                description => "Update OCI-derived runtime configuration such as entrypoint, "
+                    . "environment, OS type, architecture, working directory and stop signal.",
+                optional => 1,
+                default => 1,
+            },
+        },
+    },
+    returns => {
+        type => 'string',
+        description => "the task ID.",
+    },
+    code => sub {
+        my ($param) = @_;
+
+        my $rpcenv = PVE::RPCEnvironment::get();
+        my $authuser = $rpcenv->get_user();
+
+        my $node = extract_param($param, 'node');
+        my $vmid = extract_param($param, 'vmid');
+        my $archive = extract_param($param, 'ostemplate');
+        my $storage = extract_param($param, 'storage');
+        my $sizestr = extract_param($param, 'size');
+        my $digest = extract_param($param, 'digest');
+        my $confirm_rootfs_replace = extract_param($param, 'confirm-rootfs-replace') // 0;
+        my $update_oci_config = extract_param($param, 'update-oci-config') // 1;
+
+        die "missing explicit rootfs replacement confirmation\n"
+            if !$confirm_rootfs_replace;
+
+        my $storage_cfg = cfs_read_file("storage.cfg");
+
+        PVE::Storage::check_volume_access(
+            $rpcenv, $authuser, $storage_cfg, $vmid, $archive, 'vztmpl',
+        );
+        die "archive '$archive' is not an OCI image archive\n"
+            if !PVE::LXC::Create::archive_is_oci_format($storage_cfg, $archive);
+
+        $rpcenv->check_vm_perm($authuser, $vmid, undef, ['VM.Config.Options'])
+            if $update_oci_config;
+
+        my $lockname = 'disk';
+        my ($old_rootfs_conf, $old_rootfs, $old_volid, $target_size);
+
+        my $load_and_lock = sub {
+            PVE::LXC::Config->lock_config(
+                $vmid,
+                sub {
+                    my $conf = PVE::LXC::Config->load_config($vmid);
+                    PVE::LXC::Config->check_protection($conf, "can't replace rootfs of CT $vmid");
+                    PVE::LXC::Config->check_lock($conf);
+                    PVE::Tools::assert_if_modified($digest, $conf->{digest});
+
+                    die "cannot replace rootfs of a running container\n"
+                        if PVE::LXC::check_running($vmid);
+                    die "cannot replace rootfs of a template container\n"
+                        if PVE::LXC::Config->is_template($conf);
+                    die "cannot replace rootfs of a HA-managed container\n"
+                        if PVE::HA::Config::vm_is_ha_managed($vmid);
+                    die "cannot replace rootfs while CT has snapshots\n"
+                        if $conf->{snapshots} && scalar(keys $conf->{snapshots}->%*);
+                    die "cannot replace rootfs while CT has pending configuration changes\n"
+                        if $conf->{pending} && scalar(keys $conf->{pending}->%*);
+
+                    assert_free_unused_volume_slot($conf);
+
+                    $old_rootfs_conf = $conf->{rootfs}
+                        or die "missing rootfs configuration\n";
+                    $old_rootfs = PVE::LXC::Config->parse_volume('rootfs', $old_rootfs_conf);
+                    die "can only replace rootfs backed by a managed volume\n"
+                        if $old_rootfs->{type} ne 'volume';
+                    $old_volid = $old_rootfs->{volume};
+
+                    my (undef, undef, $owner) =
+                        PVE::Storage::parse_volname($storage_cfg, $old_volid);
+                    die "can't replace rootfs owned by another container ($owner)\n"
+                        if defined($owner) && $owner != $vmid;
+
+                    my ($current_storage) = PVE::Storage::parse_volume_id($old_volid);
+                    $storage //= $current_storage;
+
+                    my $scfg = PVE::Storage::storage_check_enabled($storage_cfg, $storage, $node);
+                    die "storage '$storage' does not support CT rootdirs\n"
+                        if !$scfg->{content}->{rootdir};
+                    $rpcenv->check($authuser, "/storage/$storage", ['Datastore.AllocateSpace']);
+                    PVE::Storage::activate_storage($storage_cfg, $storage);
+
+                    my $old_size = $old_rootfs->{size};
+                    if (!defined($old_size)) {
+                        eval { PVE::Storage::activate_volumes($storage_cfg, [$old_volid]); };
+                        die $@ if $@;
+                        eval {
+                            $old_size = PVE::Storage::volume_size_info(
+                                $storage_cfg, $old_volid, 5,
+                            );
+                        };
+                        my $err = $@;
+                        eval { PVE::Storage::deactivate_volumes($storage_cfg, [$old_volid]); };
+                        warn $@ if $@;
+                        die $err if $err;
+                    }
+                    die "unable to detect current rootfs size\n" if !defined($old_size);
+
+                    if (defined($sizestr)) {
+                        $target_size = PVE::JSONSchema::parse_size($sizestr);
+                        die "invalid size string\n" if !defined($target_size);
+                        die "unable to shrink rootfs\n" if $target_size < $old_size;
+                    } else {
+                        $target_size = $old_size;
+                    }
+
+                    PVE::LXC::Config->set_lock($vmid, $lockname);
+                },
+            );
+        };
+
+        my $worker = sub {
+            eval {
+                PVE::Cluster::log_msg(
+                    'info',
+                    $authuser,
+                    "replace rootfs of CT $vmid from OCI image '$archive'",
+                );
+
+                print "replace rootfs of CT $vmid from OCI image '$archive'\n";
+                print "old rootfs will be kept as an unused volume for rollback\n";
+                print "separate mount points mp0, mp1, ... are preserved\n";
+                print "data stored only on the old rootfs will not be present in the new rootfs\n";
+
+                my $conf = PVE::LXC::Config->load_config($vmid);
+                my $locked_digest = $conf->{digest};
+                my $locked_rootfs_conf = $conf->{rootfs};
+
+                my ($new_rootfs_conf, $new_volid, $oci_conf);
+                my $config_update_started = 0;
+                my $switched = 0;
+
+                eval {
+                    ($new_rootfs_conf, $new_volid, $oci_conf) =
+                        PVE::LXC::Create::create_oci_rootfs(
+                            $storage_cfg, $vmid, $archive, $storage, $target_size, $conf,
+                        );
+
+                    PVE::LXC::Config->lock_config(
+                        $vmid,
+                        sub {
+                            my $current_conf = PVE::LXC::Config->load_config($vmid);
+                            die "lost rootfs replacement lock\n"
+                                if !PVE::LXC::Config->has_lock($current_conf, $lockname);
+                            PVE::Tools::assert_if_modified(
+                                $locked_digest, $current_conf->{digest},
+                            );
+                            die "rootfs changed while replacement was running\n"
+                                if $current_conf->{rootfs} ne $locked_rootfs_conf;
+
+                            $current_conf->{rootfs} = $new_rootfs_conf;
+                            my $unused =
+                                PVE::LXC::Config->add_unused_volume($current_conf, $old_volid);
+                            die "old rootfs is already referenced as unused volume\n"
+                                if !defined($unused);
+
+                            apply_oci_runtime_config($current_conf, $oci_conf)
+                                if $update_oci_config;
+
+                            $config_update_started = 1;
+                            PVE::LXC::Config->write_config($vmid, $current_conf);
+                            $switched = 1;
+                        },
+                    );
+                };
+                if (my $err = $@) {
+                    if (defined($new_volid) && !$config_update_started) {
+                        eval { PVE::Storage::vdisk_free($storage_cfg, $new_volid); };
+                        warn $@ if $@;
+                    } elsif (defined($new_volid) && !$switched) {
+                        warn "not removing possibly referenced new rootfs '$new_volid'\n";
+                    }
+                    die $err;
+                }
+
+                eval { PVE::Storage::deactivate_volumes($storage_cfg, [$new_volid]); };
+                warn $@ if $@;
+                eval { PVE::Storage::deactivate_volumes($storage_cfg, [$old_volid]); };
+                warn $@ if $@;
+            };
+            my $err = $@;
+            eval { PVE::LXC::Config->remove_lock($vmid, $lockname) };
+            warn $@ if $@;
+            die $err if $err;
+        };
+
+        $load_and_lock->();
+
+        my $upid = eval {
+            $rpcenv->fork_worker('replace_rootfs_from_oci', $vmid, $authuser, $worker)
+        };
+        if (my $err = $@) {
+            eval { PVE::LXC::Config->remove_lock($vmid, $lockname) };
+            warn $@ if $@;
+            die $err;
+        }
+
+        return $upid;
+    },
+});
+
 __PACKAGE__->register_method({
     name => 'move_volume',
     path => '{vmid}/move_volume',
-- 
2.55.0.windows.2




^ permalink raw reply related	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH 3/4] pct: add OCI rootfs replacement command
  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-06 19:40 ` SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint copystring
@ 2026-07-06 19:40 ` copystring
  2026-07-06 19:40 ` SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction copystring
                   ` (2 subsequent siblings)
  5 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

Expose the API endpoint through pct as replace-rootfs-from-oci, using the generated CLI parameter handling and backend confirmation guard.

Signed-off-by: copystring <copystring@gmail.com>
---
 src/PVE/CLI/pct.pm | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/PVE/CLI/pct.pm b/src/PVE/CLI/pct.pm
index ceb0bea..ed24fee 100755
--- a/src/PVE/CLI/pct.pm
+++ b/src/PVE/CLI/pct.pm
@@ -1139,6 +1139,14 @@ our $cmddef = {
         $upid_exit,
     ],
     move_volume => { alias => 'move-volume' },
+    'replace-rootfs-from-oci' => [
+        "PVE::API2::LXC",
+        'replace_rootfs_from_oci',
+        ['vmid', 'ostemplate'],
+        { node => $nodename },
+        $upid_exit,
+    ],
+    replace_rootfs_from_oci => { alias => 'replace-rootfs-from-oci' },
     'remote-migrate' => [
         __PACKAGE__,
         'remote_migrate_vm',
-- 
2.55.0.windows.2




^ permalink raw reply related	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction
  2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
                   ` (2 preceding siblings ...)
  2026-07-06 19:40 ` SPAM: [RFC PATCH 3/4] pct: add OCI rootfs replacement command copystring
@ 2026-07-06 19:40 ` 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
  5 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

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




^ permalink raw reply related	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH pve-manager] lxc: add OCI rootfs replacement window
  2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
                   ` (3 preceding siblings ...)
  2026-07-06 19:40 ` SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction copystring
@ 2026-07-06 19:40 ` copystring
  2026-07-06 19:40 ` SPAM: [RFC PATCH pve-docs] pct: document OCI rootfs replacement semantics copystring
  5 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

Add a guarded UI flow for replacing a stopped container rootfs from an OCI image archive. The action requires VM.Config.Disk, keeps the submit button disabled until the current config digest is loaded and the user confirms the rootfs replacement, and calls the new backend endpoint.

Signed-off-by: copystring <copystring@gmail.com>
---
 www/manager6/Makefile                |   1 +
 www/manager6/lxc/CmdMenu.js          |  14 +++
 www/manager6/lxc/Config.js           |  22 ++++
 www/manager6/lxc/RootfsOciReplace.js | 149 +++++++++++++++++++++++++++
 4 files changed, 186 insertions(+)
 create mode 100644 www/manager6/lxc/RootfsOciReplace.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d4dd3f35..4f7206b9 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -207,6 +207,7 @@ JSSRC= 							\
 	dc/PCIMapView.js				\
 	dc/USBMapView.js				\
 	dc/DirMapView.js				\
+	lxc/RootfsOciReplace.js				\
 	lxc/CmdMenu.js					\
 	lxc/Config.js					\
 	lxc/CreateWizard.js				\
diff --git a/www/manager6/lxc/CmdMenu.js b/www/manager6/lxc/CmdMenu.js
index a7940e04..93bea07f 100644
--- a/www/manager6/lxc/CmdMenu.js
+++ b/www/manager6/lxc/CmdMenu.js
@@ -134,6 +134,20 @@ Ext.define('PVE.lxc.CmdMenu', {
                 },
             },
             { xtype: 'menuseparator' },
+            {
+                text: gettext('Replace Rootfs from OCI'),
+                iconCls: 'fa fa-fw fa-refresh',
+                hidden: !caps.vms['VM.Config.Disk'],
+                disabled: me.isTemplate || !stopped,
+                handler: () => {
+                    Ext.create('PVE.lxc.RootfsOciReplace', {
+                        nodename: info.node,
+                        vmid: info.vmid,
+                        canUpdateOptions: caps.vms['VM.Config.Options'],
+                        autoShow: true,
+                    });
+                },
+            },
             {
                 text: gettext('Take Snapshot'),
                 iconCls: 'fa fa-fw fa-history',
diff --git a/www/manager6/lxc/Config.js b/www/manager6/lxc/Config.js
index 3a99d6a7..9058a287 100644
--- a/www/manager6/lxc/Config.js
+++ b/www/manager6/lxc/Config.js
@@ -147,6 +147,22 @@ Ext.define('PVE.lxc.Config', {
                             });
                         },
                     },
+                    {
+                        text: gettext('Replace Rootfs from OCI'),
+                        itemId: 'replaceRootfsFromOciBtn',
+                        iconCls: 'fa fa-fw fa-refresh',
+                        hidden: !caps.vms['VM.Config.Disk'],
+                        disabled: template || running,
+                        handler: function () {
+                            Ext.create('PVE.lxc.RootfsOciReplace', {
+                                nodename: nodename,
+                                vmid: vmid,
+                                canUpdateOptions: caps.vms['VM.Config.Options'],
+                                reload: () => me.statusStore.load(),
+                                autoShow: true,
+                            });
+                        },
+                    },
                     {
                         iconCls: 'fa fa-heartbeat ',
                         hidden: !caps.nodes['Sys.Console'],
@@ -424,6 +440,12 @@ Ext.define('PVE.lxc.Config', {
             startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status === 'running' || template);
             shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running');
             me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped');
+            let replaceRootfsBtn = me.down('#replaceRootfsFromOciBtn');
+            if (replaceRootfsBtn) {
+                replaceRootfsBtn.setDisabled(
+                    !caps.vms['VM.Config.Disk'] || template || status !== 'stopped',
+                );
+            }
             consoleBtn.setDisabled(template);
 
             if (prevStatus === 'stopped' && status === 'running') {
diff --git a/www/manager6/lxc/RootfsOciReplace.js b/www/manager6/lxc/RootfsOciReplace.js
new file mode 100644
index 00000000..2b6ffe78
--- /dev/null
+++ b/www/manager6/lxc/RootfsOciReplace.js
@@ -0,0 +1,149 @@
+Ext.define('PVE.lxc.RootfsOciReplace', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pveLxcRootfsOciReplace',
+
+    title: gettext('Replace Rootfs from OCI Image'),
+    isCreate: true,
+    method: 'POST',
+    submitText: gettext('Replace Rootfs'),
+    showTaskViewer: true,
+    autoLoad: true,
+    width: 760,
+    fieldDefaults: {
+        labelWidth: 130,
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        if (!me.nodename) {
+            throw 'no node name specified';
+        }
+        if (!me.vmid) {
+            throw 'no VM ID specified';
+        }
+
+        me.url = '/nodes/' + me.nodename + '/lxc/' + me.vmid + '/replace_rootfs_from_oci';
+        me.loadUrl = '/nodes/' + me.nodename + '/lxc/' + me.vmid + '/config';
+
+        let canUpdateOptions = !!me.canUpdateOptions;
+        let warningHtml = [
+            `<b>${Ext.String.htmlEncode(gettext('Rootfs replacement is destructive.'))}</b>`,
+            Ext.String.htmlEncode(gettext('The current rootfs / will be replaced by the OCI image.')),
+            Ext.String.htmlEncode(gettext('Separate mount points such as mp0, mp1, ... are preserved.')),
+            Ext.String.htmlEncode(
+                gettext(
+                    'Data stored only on / will not be part of the new active rootfs. The old rootfs is kept as an unused volume.',
+                ),
+            ),
+        ].join('<br>');
+
+        let templateStorageSelector = Ext.create('PVE.form.StorageSelector', {
+            name: 'tmplstorage',
+            fieldLabel: gettext('Image Storage'),
+            nodename: me.nodename,
+            storageContent: 'vztmpl',
+            autoSelect: true,
+            allowBlank: false,
+            submitValue: false,
+        });
+
+        let templateSelector = Ext.create('PVE.form.FileSelector', {
+            name: 'ostemplate',
+            fieldLabel: gettext('OCI Image'),
+            nodename: me.nodename,
+            storageContent: 'vztmpl',
+            allowBlank: false,
+        });
+
+        templateStorageSelector.on('change', function (_field, storage) {
+            templateSelector.setStorage(storage, me.nodename);
+        });
+
+        me.items = [
+            {
+                xtype: 'inputpanel',
+                border: false,
+                onGetValues: function (values) {
+                    delete values.tmplstorage;
+                    delete values.confirmRootfsReplace;
+
+                    values['confirm-rootfs-replace'] = 1;
+                    values['update-oci-config'] = values['update-oci-config'] ? 1 : 0;
+
+                    if (!values.storage) {
+                        delete values.storage;
+                    }
+                    if (!values.size) {
+                        delete values.size;
+                    }
+
+                    return values;
+                },
+                columnT: [
+                    {
+                        xtype: 'displayfield',
+                        fieldLabel: gettext('Warning'),
+                        userCls: 'pmx-hint',
+                        value: warningHtml,
+                    },
+                    templateStorageSelector,
+                    templateSelector,
+                ],
+                column1: [
+                    {
+                        xtype: 'pveStorageSelector',
+                        name: 'storage',
+                        fieldLabel: gettext('Target Storage'),
+                        nodename: me.nodename,
+                        storageContent: 'rootdir',
+                        allowBlank: true,
+                        autoSelect: false,
+                        emptyText: gettext('Current rootfs storage'),
+                    },
+                    {
+                        xtype: 'proxmoxtextfield',
+                        name: 'size',
+                        fieldLabel: gettext('Root Disk Size'),
+                        allowBlank: true,
+                        emptyText: gettext('Current size'),
+                        regex: /^\d+(\.\d+)?[KMGT]?$/,
+                        regexText: gettext('Invalid size'),
+                    },
+                ],
+                column2: [
+                    {
+                        xtype: 'proxmoxcheckbox',
+                        name: 'update-oci-config',
+                        fieldLabel: gettext('Update OCI config'),
+                        checked: canUpdateOptions,
+                        disabled: !canUpdateOptions,
+                        uncheckedValue: 0,
+                    },
+                    {
+                        xtype: 'proxmoxcheckbox',
+                        name: 'confirmRootfsReplace',
+                        fieldLabel: gettext('Confirmation'),
+                        boxLabel: gettext(
+                            'I understand that the current rootfs will be replaced and that data stored only on / will no longer be active.',
+                        ),
+                        submitValue: false,
+                        uncheckedValue: 0,
+                        getErrors: function () {
+                            return this.getValue() ? [] : [gettext('Confirmation required')];
+                        },
+                        listeners: {
+                            change: function (field) {
+                                field.validate();
+                            },
+                        },
+                    },
+                ],
+            },
+        ];
+
+        me.callParent();
+
+        me.on('destroy', () => Ext.isFunction(me.reload) && me.reload());
+    },
+});
-- 
2.55.0.windows.2




^ permalink raw reply related	[flat|nested] 8+ messages in thread

* SPAM: [RFC PATCH pve-docs] pct: document OCI rootfs replacement semantics
  2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
                   ` (4 preceding siblings ...)
  2026-07-06 19:40 ` SPAM: [RFC PATCH pve-manager] lxc: add OCI rootfs replacement window copystring
@ 2026-07-06 19:40 ` copystring
  5 siblings, 0 replies; 8+ messages in thread
From: copystring @ 2026-07-06 19:40 UTC (permalink / raw)
  To: pve-devel; +Cc: copystring

Document that replacing an OCI-based container rootfs changes only rootfs, keeps separate mpX mount points, and leaves the previous rootfs as an unused volume for recovery. Also point out that data stored only on / is not carried over.

Signed-off-by: copystring <copystring@gmail.com>
---
 pct.adoc | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/pct.adoc b/pct.adoc
index cd3130f..aded5a9 100644
--- a/pct.adoc
+++ b/pct.adoc
@@ -368,6 +368,39 @@ view of a storage.
 Once the template is on a storage, you can create the container with
 `pct create` or use the wizard in the web interface.
 
+Replacing the Rootfs from an OCI Image
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For an existing OCI-based container, `pct replace-rootfs-from-oci` can replace
+the container's `rootfs` with the contents of another OCI image archive. This is
+an offline operation and requires the container to be stopped.
+
+WARNING: This operation replaces the active `/` filesystem of the container.
+Data that is stored only on the old `rootfs` will not be part of the newly
+active `rootfs`. Separate mount points configured as `mp0`, `mp1`, and so on are
+kept unchanged.
+
+The previous `rootfs` is kept as an `unusedX` volume entry after a successful
+replacement. This allows manual recovery or rollback, but it also means that the
+old volume continues to use storage space until it is explicitly cleaned up.
+
+By default, OCI-derived runtime settings such as the entrypoint, environment,
+OS type and architecture are updated to match the new image. Use
+`--update-oci-config 0` to only switch the `rootfs` while preserving the existing
+runtime settings.
+
+Before replacing the `rootfs`, make sure that all persistent application data is
+stored on separate mount points or backed up. The operation does not try to
+detect application data directories such as `/data` or `/config`.
+
+For example, to replace the `rootfs` of container `999` with a new OCI image
+archive:
+
+----
+# pct replace-rootfs-from-oci 999 local:vztmpl/alpine-3.21.tar \
+    --confirm-rootfs-replace 1
+----
+
 [[pct_settings]]
 Container Settings
 ------------------
-- 
2.55.0.windows.2




^ permalink raw reply related	[flat|nested] 8+ messages in thread

end of thread, other threads:[~2026-07-09  8:22 UTC | newest]

Thread overview: 8+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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-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
  -- strict thread matches above, loose matches on Subject: below --
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 2/4] api: lxc: add OCI rootfs replacement endpoint copystring

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