From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 09A1A1FF0E0 for ; Thu, 09 Jul 2026 10:21:38 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id D914921517; Thu, 09 Jul 2026 10:20:49 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1783366863; x=1783971663; darn=lists.proxmox.com; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=ry0an4bY7lhcS29Wdf3WmKIWaXSm/Qsn+BnzPS6xST0=; b=Kz1Sk/FwN/3nsBkjrdCEZ/0tlq0ck3vJjsO1B4YMCLnYACfZae+mhTjZmRWhjMqO63 YdsPR76vvO882juskCFF1OcEZf13LeYqYYkaCElbvB23LksfXgD30lzQRFRYVUPCpaLy D0cEe3Z8t7JQjSSm+nA5WqTBh/Eq5UYEAOimP4M/jGLkWhA44ToMg6aK+eb1qKfDOB9R Ji5JyraHf3CChtH019G3FzbeFLSi2K0NgpC1nUH0Y88JJvx/a9tpCizBha5xYRojb0hy VcrDEgMgyOvouOWuYh+JfCneRGSlRXygT/RjclBrCDb+QASIJCWJQc7do7lg4RAFmCWb h5qw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1783366863; x=1783971663; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-gg:x-gm-message-state:from :to:cc:subject:date:message-id:reply-to; bh=ry0an4bY7lhcS29Wdf3WmKIWaXSm/Qsn+BnzPS6xST0=; b=ncBUyGSfgnVnZTxyaWcXWXc89Fd9Oqug+y0wLHtgRE083I2UG6HMTo1XH/Ua5hdKQA GnhyAKRDX91VEdpyask0ocrgSYTJrojexKYe5TaY4vikL2M1xXov2TNdSe0vQecFlBQW YF/LsdI6Iziz97rTqjjZE2E0yxedxJK6wtD5+s9HRbIQ3S2TsRjZLjcVRN9w/3APoHDo 3pGRbifwibUOYL92FAGBiXD8qqbcdX6xhcP07tdUyaAT0olOFxn7wad+jcnIlC3SkGkf NDbOC15xs+ukHQcJPRxYvkDl6LdVJaxtgcJSPXFcXQ8gWehZaoqyE3WXzB62YVZN1Pj/ DsMg== X-Gm-Message-State: AOJu0YzQu0V8/k2kT1i3b66KrTRYfqaWeHDtFk+Ewnk9USfV5/XUfj34 5Jr0E2Ewfm7O/jooRjKCF0AuknNFK1KtQFoaz7/Pu9em6dWdTid2ubIROUktpe1V X-Gm-Gg: AfdE7ckUFAWv408+IQBRY5YXWfrBKrdonnQvbVhkR6NedYY/gf3T1oVvw4TAplxCSGM EwSGWD/zqXGj4EFRdgktF8TMHSOVHWkUjV7kimMdeLS9olkIigiDr2+TS0KKp54oa0eSX0l+16T +BO2x2A1ZDQDjkKfpNUL0rWxUdHfJ5kv1Zh3ZrPSrWKkKJaE9w3jwFehVdLSai8ttIz8cPdbR0U Xd/lo5ttmfdfleNOx5qjIM3b4vqf5h27R/zCLY00ZiJKWRdsEInjh/ImlI0J0ejeDtkr9+ozpfF t+IkvMixvJ2MPE9NLUYQIXiF4RMBN+H+FBY6WZHaMt7WqJPE7q7bBMjrXn6+T3jEL/qsYR/ZQIv q/oHyyCHtCJ6EC8E8Nr3DUBYVoz3pJcRcel8JarJKjaYwUHoM7CMC5lFD6hzHcnuTYlfVP8wGgF LOsFvzu09CMTHEzw== X-Received: by 2002:a05:6000:1448:b0:478:77c1:787c with SMTP id ffacd0b85a97d-47de66bcf43mr1991535f8f.56.1783366862629; Mon, 06 Jul 2026 12:41:02 -0700 (PDT) From: copystring To: pve-devel@lists.proxmox.com subject: SPAM: [RFC PATCH 2/4] api: lxc: add OCI rootfs replacement endpoint Date: Mon, 6 Jul 2026 21:40:55 +0200 Message-ID: <20260706194059.280257-3-copystring@gmail.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260706194059.280257-1-copystring@gmail.com> References: <20260706194059.280257-1-copystring@gmail.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 3 AWL 0.280 Adjusted score from AWL reputation of From: address DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature DKIM_VALID_AU -0.1 Message has a valid DKIM or DK signature from author's domain DKIM_VALID_EF -0.1 Message has a valid DKIM or DK signature from envelope-from domain DMARC_PASS -0.1 DMARC pass policy FREEMAIL_FROM 0.001 Sender email is commonly abused enduser mail provider RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust RCVD_IN_SBL_CSS 3.558 Received via a relay in Spamhaus SBL-CSS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record X-MailFrom: copystring@gmail.com X-Mailman-Rule-Hits: nonmember-moderation X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation Message-ID-Hash: MUNIYFGVRLVQBFBJHSOT64ECAWASVIM7 X-Message-ID-Hash: MUNIYFGVRLVQBFBJHSOT64ECAWASVIM7 X-Mailman-Approved-At: Thu, 09 Jul 2026 10:20:40 +0200 CC: copystring X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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