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:39:30 +0200 [thread overview]
Message-ID: <20260706193934.279992-3-copystring@gmail.com> (raw)
In-Reply-To: <20260706193934.279992-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:20 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-06 19:39 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH 1/4] lxc: create: add isolated OCI rootfs preparation copystring
2026-07-06 19:39 ` copystring [this message]
2026-07-06 19:39 ` SPAM: [RFC PATCH 3/4] pct: add OCI rootfs replacement command copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH 4/4] test: cover OCI rootfs replacement API transaction copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH pve-manager] lxc: add OCI rootfs replacement window copystring
2026-07-06 19:39 ` SPAM: [RFC PATCH pve-docs] pct: document OCI rootfs replacement semantics copystring
-- strict thread matches above, loose matches on Subject: below --
2026-07-06 19:40 SPAM: [RFC PATCH 0/4] lxc: add safe OCI rootfs replacement copystring
2026-07-06 19:40 ` SPAM: [RFC PATCH 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=20260706193934.279992-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.