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 21AEA1FF0E0 for ; Thu, 09 Jul 2026 10:20:48 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 7ADE52144B; Thu, 09 Jul 2026 10:20:47 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1783366855; x=1783971655; 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=sqOG16rZVT1KBEDBp7q5GVylc1aVaj+pUOLQI3dWpr5w+uQeCKmnfDZ/riIXu09sAX 1xTdnJFLRFhEWgM5QEnGjRETCXFc6j2CGwy2a+1grI3DYzub5rn/ABuLBinwmTCOl/64 L2plhPNZkpv0yhPM6sZPBdzpT0oYK370B5Z2MIVvyYDxPnOcJf2Zm1tDQezHnG2iANE/ miqBGMfGxtSSm6L8WP88GusddF65nmokGCPPcGyRMfdk8BihemFRXQElAhGueJQP0d7t 3iFxVZmW5Bv3ATW3EfDGmUndJ9NH6DJ+5DrVJDnC8b4WUBS3RZt9nFTo7mKvcnewIo82 nAPg== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1783366855; x=1783971655; 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=OdqFDCRfC1YTnYjJK6hEHLxoRcf1Vk7KQrBwfaAxBnSuJkKA9mC4pb4G8xiTVrUth/ 9nw4gkN89Bs0l3orZC6GK9LfYTbgXnmmI53qoWdA6Hj3CKnK579NMzIbWH4rijHTl9DC XV5yoEYzl0TwYwhGQcHZdvNSdj8eGsCBh595TxjCmQKLscfBlG8sKtbGyC4rBwBKI7gb OHFTxt9/uHNhkFWVuJL6YzbXg24VgXr4arcQZUKwHWbunwNbru5IWOZF+Vp84sXX/QTT Vc9cfQlHe5ZzHilkJFTDUTGH+WV93gDM60427kqDHjhuFf1heQPDBlEitaNP4SIP3CJI pUTQ== X-Gm-Message-State: AOJu0YwWllehc8Le08EdZHQ1am14I9dN4bZzWgl8PLg+6zWQpNDrATwr Xo8bZgOo/UzlP2G7QfvCedAOYbrCjV5mHDUU2hELZPF5mVKr8ZRdctjdxV3n9DiY X-Gm-Gg: AfdE7cmbDbcafOWtF1VyMQRPiOsVp38L2DgvErOi0CMC0PrLXO0GYmFXx/U1HXURPDO HCDtVU6gzimNJnMSSf7U2khOmw15f22fOQCpeSUvOTEg5dPRQpfHJugN4zKyr5EkzsmNd05zeJk vnvU0h+Cj9vVn8QHi6Up2URfChENef1EQOWuvy3str7LxGEpAc8rtVt/Qf3xd8SYx6rcbxxg4Lx M3Z9o3g8k+BydPrH3BICBoNLYTrM1rVkN9XWov8SnzVZJdX94hbwuLdUwR2CLOPmM1xW3+KgjfJ RQqAwxnKl2Zb+fZTrTWrhm0tnqp2vQzS01dB1We6Hj/jNs8j+11g1XFYzG77s4qMPqEHm9uobdM vsWTvym11/IDipUNa3H5AFMZXrwY4BkKIS6RRkpIEkbsSklxcDLBQTqwwTIFRuWHwRun1L8ERsF Od3q6nP/1IZM8CAA== X-Received: by 2002:a05:600c:19d0:b0:492:62d9:4e57 with SMTP id 5b1f17b1804b1-493df037a30mr19009405e9.2.1783366855123; Mon, 06 Jul 2026 12:40:55 -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:39:30 +0200 Message-ID: <20260706193934.279992-3-copystring@gmail.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260706193934.279992-1-copystring@gmail.com> References: <20260706193934.279992-1-copystring@gmail.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 3 AWL 0.150 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: ZXUJX5CIPFFBC3QWZHS2HKEIQAOZI7U4 X-Message-ID-Hash: ZXUJX5CIPFFBC3QWZHS2HKEIQAOZI7U4 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