Reuse OCI images and apply resource edits without rebuilding

This commit is contained in:
Jonatan
2026-10-17 17:11:26 +02:00
parent ce1a3de719
commit 784ce13c14
11 changed files with 481 additions and 14 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Apply CPU/RAM-only proposals to the existing guest, without an image archive."""
import copy
import hashlib
from pathlib import Path
import uuid
import oci_instances as instances
import oci_instance_transaction as transaction
from oci_installation_state import parse_config, sha
from oci_ui import translate, msg_info, msg_ok, msg_warn
CONFIG_DIR = Path('/etc/pve/lxc')
def changes(record, candidate, config):
"""Return a narrow native edit, or None when the normal installer is needed."""
previous = transaction.with_default_healthcheck(copy.deepcopy(record))
candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate))
old, new = previous['deployment'], candidate['deployment']
if previous['template'] != candidate['template'] or old.get('host_monitor'):
return None
if {k: v for k, v in old.items() if k != 'resources'} != {
k: v for k, v in new.items() if k != 'resources'}:
return None
original, desired = old.get('resources', {}), new.get('resources', {})
changed = {key for key in original.keys() | desired.keys() if original.get(key) != desired.get(key)}
if not changed or changed - {'cores', 'memory_mb'}:
return None
cfg = parse_config(config)
cpu = 'cpulimit' if 'cpulimit' in cfg and 'cores' not in cfg else 'cores'
parameters = {}
for key in sorted(changed):
value = desired.get(key)
if isinstance(value, bool) or not isinstance(value, int) or value < (128 if key == 'memory_mb' else 1):
return None
native = 'memory' if key == 'memory_mb' else cpu
if native not in cfg:
return None
parameters[native] = str(value)
return parameters
def guarded_set(vmid, installation_id, expected, parameters):
# pct uses this SHA1 under its own configuration lock. Sample the native
# file before checking pct's view, so a concurrent edit cannot be accepted
# accidentally by taking a fresh digest after validation.
native = (CONFIG_DIR / f'{vmid}.conf').read_bytes()
current = instances.command('pct', 'config', str(vmid))
if current != expected or instances.identity(current) != installation_id:
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
command = ['pct', 'set', str(vmid), '--digest', hashlib.sha1(native).hexdigest()]
for key, value in sorted(parameters.items()):
command += ['--' + key, value]
transaction.run(*command)
def expected_config(before, after, parameters):
def unrelated(config):
return b''.join(line for line in config.splitlines(keepends=True)
if line.split(b':', 1)[0].decode() not in parameters)
new = parse_config(after)
return unrelated(before) == unrelated(after) and all(new.get(key) == value for key, value in parameters.items())
def apply(root, record, candidate, config, parameters):
"""The caller holds the instance lock and has checked image identity/preflight."""
if changes(record, candidate, config) != parameters:
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
vmid = record['vmid']
directory = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex
instances.write(directory / 'before.json', {'record': record, 'config': config.decode()})
transaction.open_log(directory)
msg_info(translate('Applying CPU and RAM changes without rebuilding the container...'))
applied = False
after = None
try:
guarded_set(vmid, record['installation_id'], config, parameters)
applied = True
after = instances.command('pct', 'config', str(vmid))
if not expected_config(config, after, parameters):
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
observed = record['observed']
fresh = instances.observe(vmid, record['installation_id'], observed['archive_path'],
observed['resolved_registry_digest'], observed['image'])
if fresh['config_sha256'] != sha(after):
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
updated = copy.deepcopy(record)
updated.update(deployment=copy.deepcopy(candidate['deployment']), observed=fresh)
instances.write(instances.location(root, vmid), updated)
except Exception:
if applied:
try:
current = instances.command('pct', 'config', str(vmid))
if not expected_config(config, current, parameters):
raise ValueError('Concurrent configuration change; resource rollback refused')
previous = parse_config(config)
guarded_set(vmid, record['installation_id'], current,
{key: previous[key] for key in parameters})
instances.write(instances.location(root, vmid), record)
except Exception as error:
transaction.log(f'resource rollback: {type(error).__name__}')
msg_warn(translate('The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.'))
raise
# Updating the carried record is best effort here, as it is after other
# management operations. The normal menu also refreshes it on return.
try:
import oci_carried_record
oci_carried_record.carry(root, vmid, mount_stopped=False)
except (OSError, ValueError, RuntimeError):
transaction.log('carried record will be refreshed on the next management entry')
msg_ok(translate('Changes applied. Data kept.'))
return directory
+39 -14
View File
@@ -8,6 +8,7 @@ import re
import shlex
import subprocess
import sys
import tarfile
import tempfile
import oci_instances as instances
@@ -56,7 +57,7 @@ def run_quiet(args, error, capture=False):
process.wait()
def resolve_archive(desired, config, current=None, check=None, required_digest=None):
def resolve_archive(desired, config, current=None, check=None, required_digest=None, local_archives=()):
# Shared by individual and coordinated operations; no guest mutation here.
# When the registry still serves the current digest nothing is downloaded.
reference = desired['template']['container_contract']['image']['reference']
@@ -83,6 +84,27 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
return None, digest
if check:
check()
verifier = Path(__file__).with_name('verify_oci_archive.py')
def intact(path):
try:
run_quiet([sys.executable, str(verifier), str(path)],
translate('The image did not pass the integrity check'))
return same_image(candidate, image_from_archive(str(path)))
except (RuntimeError, OSError, ValueError, KeyError, tarfile.TarError):
return False
# Initial installations and updates use different archive names. The
# instance record knows the actual archive; validate it before looking
# only for the deterministic update-cache name. Never delete a rejected
# recorded path: another instance may still refer to it.
for saved in local_archives:
path = Path(saved) if saved else None
if path and not path.is_symlink() and path.is_file():
msg_info(translate('Verifying the image integrity...'))
if intact(path):
msg_ok(translate('Using the verified image from the cache'))
return path, digest
storage = desired['deployment']['template_storage']
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage):
raise ValueError(translate('Invalid template storage'))
@@ -91,16 +113,6 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
archive.parent.mkdir(parents=True, exist_ok=True)
if archive.is_symlink():
raise ValueError(translate('Unsafe OCI archive path'))
verifier = Path(__file__).with_name('verify_oci_archive.py')
def intact(path):
try:
run_quiet([sys.executable, str(verifier), str(path)],
translate('The image did not pass the integrity check'))
except RuntimeError:
return False
return same_image(candidate, image_from_archive(str(path)))
if archive.exists():
msg_info(translate('Verifying the image integrity...'))
if intact(archive):
@@ -181,10 +193,23 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
changes = transaction.external_changes(record, config)
transaction.preflight(record, desired, config)
msg_ok(translate('Container checked'))
current = record['observed']['image'] if operation == 'update' else None
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
resource_changes = None
if operation == 'recreate' and not keep_backup:
import oci_resource_modify
resource_changes = oci_resource_modify.changes(record, desired, config)
current = record['observed']['image'] if operation == 'update' or resource_changes else None
check = lambda: work_backup.locate(
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid)
if operation == 'recreate':
archive, digest = resolve_archive(desired, config, current, check,
local_archives=[record['observed'].get('archive_path')])
else:
archive, digest = resolve_archive(desired, config, current, check)
if archive is None:
if resource_changes:
import oci_operation_notice
with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)):
return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes)
msg_ok(translate('The image is already up to date; nothing was changed.'))
return
file_storage = None