Preserve Modify fallback and detached disks after native edits

This commit is contained in:
Jonatan
2026-10-17 18:57:27 +02:00
parent b70e968f73
commit ac42bea70f
8 changed files with 468 additions and 30 deletions
+51 -5
View File
@@ -320,6 +320,36 @@ def mounts(config):
return result
def unused_volumes(config):
return {key: value for key, value in parse_config(config).items()
if re.fullmatch(r'unused[0-9]+', key)}
def move_retained_unused(state, to_stage):
"""Unused disks are not in vzdump. Park and return them separately, using
PVE's volume reassignment API, never --delete (which frees an unused disk).
The journal's keys and both ownership markers also make retries safe."""
keys = set(state.get('retained_unused', {}))
if not keys:
return
vmid, stage = state['vmid'], state['stage']
guest_marker = state['record']['installation_id']
holder_marker = 'proxmenux-transaction=' + state['id']
source, target = (vmid, stage) if to_stage else (stage, vmid)
source_marker, target_marker = ((guest_marker, holder_marker) if to_stage
else (holder_marker, guest_marker))
for key in sorted(keys):
present = unused_volumes(owned(source, source_marker))
destination = unused_volumes(owned(target, target_marker))
if key not in present:
if key in destination:
continue # Already reassigned before an interruption.
raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced'))
if key in destination or (to_stage and present.keys() - keys):
raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced'))
run('pct', 'move-volume', str(source), key, '--target-vmid', str(target), '--target-volume', key)
def is_file_bind(mount, files=()):
return mount['type'] == 'host-bind' and (mount['source'] in files or Path(mount['source']).is_file())
@@ -492,7 +522,16 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
runtime_settings.check(config, deployment, record['vmid'])
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
preserved_keys = {'nameserver', 'searchdomain', 'timezone', 'protection'} if in_place else set()
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - preserved_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k) or (in_place and re.fullmatch(r'unused[0-9]+', k))}:
detached = unused_volumes(config)
if baseline and detached != unused_volumes(record['observed'].get('config', '').encode()):
raise ValueError(translate('The container disks do not match the saved record'))
used_volumes = {value.split(',', 1)[0] for key, value in cfg.items()
if key == 'rootfs' or re.fullmatch(r'mp[0-9]+', key)}
if (len(set(detached.values())) != len(detached)
or any(not re.fullmatch(r'[A-Za-z0-9_.-]+:[^\s,]+', value) or value in used_volumes
for value in detached.values())):
raise ValueError(translate('The container disks do not match the saved record'))
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - preserved_keys - {k for k in keys if re.fullmatch(r'(mp|dev|unused)[0-9]+', k)}:
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False
if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \
@@ -549,7 +588,9 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
or mount.get('ro', '0') != ('1' if old[target].get('read_only') else '0')):
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
for target in old.keys() & new.keys():
if any(old[target].get(k) != new[target].get(k) for k in ('type', 'source', 'size_gb')):
directory_bind = old[target]['type'] == new[target]['type'] == 'host-bind'
fixed = ('type', 'size_gb') if directory_bind else ('type', 'source', 'size_gb')
if any(old[target].get(k) != new[target].get(k) for k in fixed):
raise ValueError(translate('Changing the storage or size of a disk requires a migration; empty disks are not created'))
protected = ('/bin', '/sbin', '/etc', '/usr', '/lib', '/lib64', '/proc', '/sys', '/dev', '/run')
for target in new.keys() - old.keys():
@@ -1043,6 +1084,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
for p, m in actual.items() if p in required and not m['volume'].startswith('/')])
state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation,
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
'retained_unused': unused_volumes(before),
'original_description': description,
'archive': str(archive), 'archive_sha256': filehash(archive),
'registry_digest': registry_digest or image['manifest_digest'],
@@ -1116,6 +1158,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
else:
owned(stage, 'proxmenux-transaction=' + state['id'])
run('pct', 'move-volume', str(vmid), mount['key'], '--target-vmid', str(stage), '--target-volume', mount['key'])
move_retained_unused(state, to_stage=True)
checkpoint(journal, state, 'data-parked')
if show:
msg_ok(translate('Data volumes protected'))
@@ -1125,8 +1168,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
msg_info(translate('Installing the new image...') if update else translate('Recreating the container...'))
current = owned(vmid, record['installation_id'])
expected = b''.join(line for line in before.splitlines(keepends=True)
if not re.match(rb'mp[0-9]+: ', line))
if mounts(current) or current != expected:
if not re.match(rb'(mp|unused)[0-9]+: ', line))
if mounts(current) or unused_volumes(current) or current != expected:
raise ValueError(translate('A concurrent change was detected; the container is not removed'))
host_mounts.verify_sources(original_sources)
gpu_devices.verify(original_gpu)
@@ -1141,6 +1184,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
install_candidate(root, journal, state, progress)
restore_description(vmid, state)
restore_firewall(vmid, state)
move_retained_unused(state, to_stage=False)
if coordinated:
for key, value in state['preserved_stack_config'].items():
run('pct', 'set', str(vmid), '--' + key, value)
@@ -1340,8 +1384,9 @@ def recover(root, journal):
stop(vmid)
current = owned(vmid, record['installation_id'])
gpu_devices.check_recovery_entries(current, state)
if any(re.fullmatch(r'unused[0-9]+', k) for k in parse_config(current)):
if unused_volumes(current).keys() - state.get('retained_unused', {}).keys():
raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced'))
move_retained_unused(state, to_stage=True)
known_binds = {(m['source'], m['container_path'])
for plan in (state['record']['deployment'], state['candidate_contract']['deployment'])
for m in plan.get('mounts', []) if m['type'] == 'host-bind'}
@@ -1369,6 +1414,7 @@ def recover(root, journal):
host_mounts.verify_sources(state.get('original_host_sources', {}))
run('pct', 'restore', str(vmid), backup, '--force', '1', '--storage',
state['record']['deployment']['rootfs']['storage'])
move_retained_unused(state, to_stage=False)
state['restore_config_sha256'] = sha(owned(vmid, state['record']['installation_id']))
checkpoint(journal, state, 'checking-recovery')
if show: