mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Reuse OCI images and apply resource edits without rebuilding
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user