From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 76FCE1FF138 for ; Mon, 20 Jul 2026 13:29:11 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 7C345214A7; Mon, 20 Jul 2026 13:29:10 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1784390786; x=1784995586; 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:content-type; bh=nHb5wgejGhgGLLlFLgSQpCsDtATBbKpor/y4jEHWnKo=; b=E+HlXqcoHhayb2v//oTS23PUHQZdhs8Fv/Kkyz/HziVaxUuq2IhQTrlqzQ5jYyGefL roQaSb1ITNSWdwcgj8s5PiaC3usnbWSI9swczUG48NgK2xxAH7jkbp20YtrXXIt7m0Sk x1B+jRve9liHpKs3CY6CDga8Lr+/krwjzUTsiGDBrACsar4NabKKBOpE+K13rCIjMtsB AgJPK3svAsZWQAqTdeM3pbiocW58EpwrxDOPf8wT81koGN7GjnFE/SpOBksyZcFPYheg ZMNHRE8WDBppVItN2Ip5I5+0+LOeGK+2dcaoeWqpEBgAdSRoCnBkr6PxiyZE1EuXLXXN gWlQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1784390786; x=1784995586; 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:content-type; bh=nHb5wgejGhgGLLlFLgSQpCsDtATBbKpor/y4jEHWnKo=; b=LMy4U4zHlOZhL+tU5KTeL/som/6whxY0NY3DMujFxf+qEFON/D9u0tlQQsCAIDWB21 EzUY7OrrDk3oYrOCBk3gIxSTEjmLrZWluvqDKdWNQbIvhOZSCp43CTpqkStmkl0tJHN8 wzA6tvGmHh3rWkZvTHV01nMP40s/KHdOyJN7eCN1bXn50LrgEec5hZ8d0nCtAunoJT1u +8c/d+jJhFDlXPJzQEDRYkzh8h4mvY1duRC2kd3AZ7LMd+0RNnNWl5+IzOAFY/ASt/w9 GU9IAnL7d6juwvaXi0vX1MEW+mo0povOUoUv4A1stYHgPrZFWAet9tMTYEGLEF57xPR/ sWEA== X-Gm-Message-State: AOJu0YzHVAYnHIdVb7F8cYZGR25BxuYGJJBxERenRwY3naF9m32igVPA r3xtXSrWd/4fQlt3LzJLaFqQ2OqdSa3713ITBnzmLPAcYIh/2I/d0nnAlSwsfg== X-Gm-Gg: AfdE7cn3nBDm9bYmUa3VVL9qOTb/2jHna20Cqwpxrp1g4EIOJftKcphcNT9cQ6Edgcm KMTy7IHNPP/04BLBqk9jVFkJO3qab8BFd5rAjST6A+5u+8MKzExwnyHlMzyhdZFOyCxrF7DRRy8 MKfTB+tjs02KgTPQcvpAqJA3AmWm3tLwt5Bblu3Y/Ff/6W9BDVb3EhHukJcjv2bdHPNCTFfl7nj HbwC7WvgixeQHkaG2vL0HAwGccuVVl0GUV/n4MZGKGg5CyptywWRD1oqXa86NAWvaEacb0k+0pv wt46Yx8kRi77b94FOFxlrlGTIWu1Mu8JHk/U0xze+WKETcREXIyP593jO1EMZfmabUJZ5G0js5r tjYVYHUR2tUwBoD3oIWkybUm4l+GnaSHbrzE4vchPGfXCVmTMh9SYBUM2yRqNaOMl5QTs3TpOeQ HDTnI= X-Received: by 2002:a05:6000:228a:b0:47f:6f89:7db5 with SMTP id ffacd0b85a97d-47f6f8982dbmr1778312f8f.57.1784390785935; Sat, 18 Jul 2026 09:06:25 -0700 (PDT) From: copystring To: pve-devel@lists.proxmox.com subject: SPAM: [RFC PATCH v2 2/4] api: lxc: add OCI rootfs replacement endpoint Date: Sat, 18 Jul 2026 18:06:00 +0200 Message-ID: <3a64078e3480d62f51484263ba55ed1f93ea493a.1784389742.git.copystring@gmail.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: References: MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 3 AWL 0.216 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: Q2LANZOGZRDKO4ZHNZSTN2A2ABYQFIXO X-Message-ID-Hash: Q2LANZOGZRDKO4ZHNZSTN2A2ABYQFIXO X-Mailman-Approved-At: Mon, 20 Jul 2026 13:29:01 +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, requires explicit confirmation, preserves separate mpX mount points, and never tries to merge data from the old rootfs. 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.3