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
next prev 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