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 2/4] api: lxc: add OCI rootfs replacement endpoint
Date: Mon,  6 Jul 2026 21:40:55 +0200	[thread overview]
Message-ID: <20260706194059.280257-3-copystring@gmail.com> (raw)
In-Reply-To: <20260706194059.280257-1-copystring@gmail.com>

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




  parent reply	other threads:[~2026-07-09  8:21 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH 1/4] lxc: create: add isolated OCI rootfs preparation copystring
2026-07-06 19:40 ` copystring [this message]
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

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=20260706194059.280257-3-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