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
@@ -34,6 +34,9 @@ jobs:
run: |
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
- name: Run offline registry account tests
env:
PYTHONDONTWRITEBYTECODE: '1'
+3 -14
View File
@@ -142,7 +142,7 @@ An application made of several images is one entry of the catalog and one instal
| Operation | What changes | What is kept |
|---|---|---|
| **Update** | The rootfs is replaced by the image the saved tag publishes today | Record, container disks, host directories, network and devices |
| **Modify** | Choose a setting, review it, then apply native LXC changes to the existing container; runtime settings may require a restart | Installed image, rootfs, persistent data and unrelated Proxmox settings |
| **Modify** | Edit and confirm the configuration; supported native settings apply directly, while other changes use the existing recreation flow | Native edits keep the installed image and rootfs; both paths preserve persistent data |
| **Recreate** (multi-container) | Every container is rebuilt from the image it was installed with | Data, network and devices |
| **Watchdog** | Whether the application is started again when it stops on its own | Everything; only the choice is saved |
| **Remove** | The LXC, or every container of the application, its disks and its record | Host directories, with their content |
@@ -159,20 +159,9 @@ Settings changed in Proxmox VE after the installation, such as memory, cores or
A rolling tag is not an unattended update: the digest changes only through an explicit update.
For standalone applications, CPU and RAM changes apply live. Network settings,
environment overrides, directory mounts, additional managed volumes and supported
native devices use a controlled stop/start when needed. Modify does not query the
registry or download an image for these edits, even if the cached archive has
already been cleaned. Removing a managed mount detaches its disk without deleting
its data. An optional retained backup does not force recreation.
For standalone applications, CPU and RAM changes apply live. Supported network settings, environment overrides, directory mounts, additional managed volumes and native devices use a controlled stop/start when needed. Modify does not query the registry or download an image for these native edits, even if the cached archive has already been cleaned. Removing a managed mount detaches its disk without deleting its data; subsequent image updates park that unused disk and return it to the container. An optional retained backup does not force recreation.
The native path checks the saved configuration and installation identity, keeps a
private before/after journal, and restores the original settings if applying them
or checking the restarted service fails. It refuses to overwrite concurrent edits.
Application-specific installer adaptations (such as changing an NVIDIA runtime or
post-start configuration) are not silently converted into a full recreation:
unsupported same-image changes stop with an explanation before modifying the CT.
Image changes and coordinated stack operations retain their separate update flow.
The native path checks the saved configuration and installation identity, keeps a private before/after journal, and restores the original settings if applying them or checking the restarted service fails. It refuses to overwrite concurrent edits. Changes that require installer adaptations, such as NVIDIA runtime changes, GPU permission/environment setup, file mounts, completion notes or a refreshed catalog template, use the existing validated recreation flow. The route is chosen before applying any settings, so one proposal is not partially applied by both paths. The recreation flow reuses a verified cached archive when it matches the resolved image; it downloads an image when a suitable archive is unavailable. Image changes and coordinated stack operations retain their separate update flow.
## From a Compose file or a docker run command to an oci-compose
+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:
+32 -2
View File
@@ -86,6 +86,24 @@ def changes(record, candidate, config):
if 'mounts' in changed:
if any(transaction.is_file_bind(m) for plan in (before, after) for m in plan.get('mounts', [])):
return None
if transaction.nested_mounts.without(config) != config:
# The installer also regenerates the recursive bind entries and
# hooks associated with these mounts.
return None
old_mounts = {m['container_path']: m for m in before.get('mounts', [])}
for mount in after.get('mounts', []):
original = old_mounts.get(mount['container_path'])
# Permission grants and installer-specific mount attributes need
# their normal installer, including when only their source changes.
if mount.get('grant_access'):
return None
if original:
fields = {k for k in original.keys() | mount.keys() if original.get(k) != mount.get(k)}
native_fields = {'read_only', 'backup', 'create_if_missing'}
if original['type'] == mount['type'] == 'host-bind':
native_fields.add('source')
if fields - native_fields:
return None
return changed
@@ -174,8 +192,14 @@ def plan(record, candidate, config):
original = next(m for m in old['mounts'] if m['container_path'] == target)
if mount == original: continue
fields = dict(p.split('=', 1) for p in actual[target]['value'].split(',')[1:] if '=' in p)
fields['ro'] = '1' if mount.get('read_only') else '0'
values[actual[target]['key']] = actual[target]['volume']+','+','.join(k+'='+v for k, v in fields.items())
source = actual[target]['volume']
if mount['type'] == 'host-bind':
source = host_mounts.valid_path(mount['source'])
if not Path(source).is_dir() and not mount.get('create_if_missing'):
raise ValueError(translate('The host directory does not exist'))
fields.update(mp=target, backup='1' if mount['backup'] else '0',
ro='1' if mount.get('read_only') else '0')
values[actual[target]['key']] = source+','+','.join(k+'='+v for k, v in fields.items())
continue
key = next('mp'+str(n) for n in range(256) if 'mp'+str(n) not in used)
used.add(key)
@@ -203,6 +227,12 @@ def verify_values(config, operation):
if environment(config) != dict(item.split('=', 1) for item in value.split('\0')):
raise ValueError(translate('Proxmox could not apply the container settings'))
elif re.fullmatch(r'(mp|dev|net)[0-9]+', key):
if re.fullmatch(r'mp[0-9]+', key):
source = value.split(',', 1)[0]
actual_source = cfg.get(key, '').split(',', 1)[0]
allocating = re.fullmatch(r'[A-Za-z0-9_-]+:[0-9]+', source)
if (not allocating and actual_source != source) or (allocating and not actual_source.startswith(source.split(':')[0]+':')):
raise ValueError(translate('Proxmox could not apply the container settings'))
desired = dict(p.split('=', 1) for p in value.split(',') if '=' in p)
actual = dict(p.split('=', 1) for p in cfg.get(key, '').split(',') if '=' in p)
defaults = {'uid': '0', 'gid': '0', 'deny-write': '0', 'ro': '0', 'backup': '0', 'mode': '0660', 'firewall': '0'}
+3 -4
View File
@@ -224,10 +224,9 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
backup_native_settings(vmid, keep_backup)
return oci_native_modify.apply(instances.ROOT, record, desired, config,
acknowledge_external_data=acknowledge_external_data)
if (operation == 'recreate'
and record['template']['container_contract']['image']['reference']
== desired['template']['container_contract']['image']['reference']):
raise ValueError(translate('These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.'))
# An unsupported native edit still uses the existing installer. Decide
# the route before changing anything; actual validation/API failures
# must not silently switch to a destructive fallback.
transaction.preflight(record, desired, config)
msg_ok(translate('Container checked'))
current = record['observed']['image'] if operation == 'update' else None
+219
View File
@@ -0,0 +1,219 @@
"""Maintainer review: fallback, complete mount edits and retained unused disks."""
import contextlib
import copy
from pathlib import Path
import re
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote'))
import oci_instances as instances
import oci_instance_transaction as transaction
import oci_native_modify as native
import oci_update_current as update
import oci_operation_notice
from test_modify_image_reuse import record, CONFIG
def installation():
result=record()
result['deployment'].update(rootfs={'storage':'local-zfs','size_gb':8}, onboot=True,
security={'unprivileged':True})
result['observed']['image']['defaults']={}
config=CONFIG+b'unprivileged: 1\nonboot: 1\nnet0: name=eth0,bridge=vmbr0,ip=dhcp,hwaddr=AA:BB:CC:DD:EE:FF\n'
result['observed'].update(config=config.decode(), config_sha256=transaction.sha(config))
return result,config
class FallbackTests(unittest.TestCase):
def test_all_installer_adaptations_fall_back_before_any_native_write(self):
saved,config=installation()
def nvidia(c):c['deployment']['devices']=[{'kind':'nvidia-runtime','host_path':'/dev/nvidia0'}]
def groups(c):c['deployment']['devices']=[{'host_path':'/dev/dri/renderD128','append_host_device_gid_to_environment':'GROUPS'}]
def permissions(c):
c['deployment']['devices']=[{'host_path':'/dev/dri/renderD128'}]
c['deployment']['device_permissions']={'uid':1000}
mutations=[nvidia,groups,permissions,
lambda c:c['deployment'].update(completion_notes=['Installer note']),
lambda c:c['deployment'].update(post_start_configurations=[{'id':'adapter'}]),
lambda c:c['template']['catalog_ui'].update(title='Refreshed catalog template'),
lambda c:c['deployment'].update(mounts=[{'type':'host-bind','source':'/host/file','container_path':'/config/file','backup':False,'size_gb':None}])]
for mutation in mutations:
with self.subTest(mutation=mutation),tempfile.TemporaryDirectory() as folder:
candidate=copy.deepcopy(saved);mutation(candidate)
root=Path(folder);instances.write(instances.location(root,115),saved)
with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
patch.object(instances,'command',return_value=config), \
patch.object(transaction,'external_changes',return_value={}),patch.object(transaction,'preflight'), \
patch.object(transaction,'is_file_bind',side_effect=lambda m:m.get('source')=='/host/file'), \
patch.object(native,'apply',side_effect=AssertionError('partial native apply')), \
patch.object(update,'resolve_archive',return_value=(Path('/cached.tar'),'sha256:resolved')) as resolve, \
patch.object(transaction,'apply') as recreate, \
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
update.update(115,proposal={'operation':'recreate','candidate':candidate})
recreate.assert_called_once();resolve.assert_called_once()
self.assertEqual(resolve.call_args.kwargs['local_archives'],[saved['observed']['archive_path']])
def test_native_validation_failure_never_falls_back_to_recreation(self):
saved,config=installation();candidate=copy.deepcopy(saved)
candidate['deployment']['environment']=[{'name':'INVALID NAME','value':'value'}]
with tempfile.TemporaryDirectory() as folder:
root=Path(folder);instances.write(instances.location(root,115),saved)
with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
patch.object(instances,'command',return_value=config), \
patch.object(transaction,'preflight'),patch.object(transaction,'external_changes',return_value={}), \
patch.object(transaction,'apply') as recreate,patch.object(update,'resolve_archive') as resolve, \
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
with self.assertRaises(ValueError):update.update(115,proposal={'operation':'recreate','candidate':candidate})
recreate.assert_not_called();resolve.assert_not_called()
def test_native_api_failure_never_falls_back_after_starting_the_edit(self):
saved,config=installation();candidate=copy.deepcopy(saved)
candidate['deployment']['environment']=[{'name':'VALID','value':'value'}]
with tempfile.TemporaryDirectory() as folder:
root=Path(folder);instances.write(instances.location(root,115),saved)
with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
patch.object(instances,'command',return_value=config), \
patch.object(transaction,'preflight'),patch.object(transaction,'external_changes',return_value={}), \
patch.object(transaction,'apply') as recreate,patch.object(update,'resolve_archive') as resolve, \
patch.object(native,'apply',side_effect=RuntimeError('native API failed')), \
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
with self.assertRaisesRegex(RuntimeError,'native API failed'):
update.update(115,proposal={'operation':'recreate','candidate':candidate})
recreate.assert_not_called();resolve.assert_not_called()
class MountTests(unittest.TestCase):
def setUp(self):
self.record,self.config=installation()
self.mount={'type':'host-bind','source':'/mnt/old','container_path':'/media',
'size_gb':None,'backup':False,'read_only':False,'create_if_missing':False}
self.record['deployment']['mounts']=[self.mount]
self.config+=b'mp3: /mnt/old,mp=/media,backup=0,ro=0,replicate=0\n'
self.record['observed'].update(config=self.config.decode(),config_sha256=transaction.sha(self.config))
self.candidate=copy.deepcopy(self.record)
def test_existing_bind_source_and_flags_are_applied_with_other_options_preserved(self):
self.candidate['deployment']['mounts'][0].update(source='/mnt/new',read_only=True)
with patch.object(Path,'is_dir',return_value=True):
plan=native.plan(self.record,self.candidate,self.config)
self.assertEqual(plan['values'],{'mp3':'/mnt/new,mp=/media,backup=0,ro=1,replicate=0'})
self.assertTrue(plan['restart']);self.assertEqual(plan['delete'],[])
def test_missing_replacement_directory_is_rejected_unless_creation_was_requested(self):
self.candidate['deployment']['mounts'][0]['source']='/mnt/new'
with patch.object(Path,'is_dir',return_value=False):
with self.assertRaisesRegex(ValueError,'directory does not exist'):
native.plan(self.record,self.candidate,self.config)
self.candidate['deployment']['mounts'][0]['create_if_missing']=True
self.assertIn('/mnt/new,',native.plan(self.record,self.candidate,self.config)['values']['mp3'])
def test_verification_detects_a_source_that_was_not_applied(self):
operation={'values':{'mp3':'/mnt/new,mp=/media,backup=0,ro=0'},'delete':[]}
with self.assertRaisesRegex(ValueError,'could not apply'):native.verify_values(self.config,operation)
native.verify_values(self.config.replace(b'/mnt/old',b'/mnt/new'),operation)
def test_unknown_mount_options_and_permission_grants_use_installer(self):
for fields in ({'grant_access':True},{'mount_options':['noexec']},{'type':'managed-volume','source':'local-zfs','size_gb':2,'backup':True}):
candidate=copy.deepcopy(self.candidate);candidate['deployment']['mounts'][0].update(fields)
self.assertIsNone(native.changes(self.record,candidate,self.config))
def test_recursive_bind_adaptations_use_installer(self):
self.candidate['deployment']['mounts'][0]['read_only']=True
derived=b'lxc.mount.entry: /mnt/old media none rbind,rslave,create=dir 0 0\n'
self.assertIsNone(native.changes(self.record,self.candidate,self.config+derived))
def test_storage_and_disk_size_changes_keep_existing_migration_guard(self):
self.record['deployment']['mounts'][0].update(type='managed-volume',source='local-zfs',size_gb=1,backup=True)
self.config=self.config.replace(b'/mnt/old,mp=/media,backup=0',b'local-zfs:subvol-115-disk-1,mp=/media,backup=1')
self.record['observed']['config']=self.config.decode()
for fields in ({'size_gb':2},{'source':'other-storage'}):
candidate=copy.deepcopy(self.record);candidate['deployment']['mounts'][0].update(fields)
self.assertIsNone(native.changes(self.record,candidate,self.config))
with self.assertRaisesRegex(ValueError,'migration'):transaction.preflight(self.record,candidate,self.config)
class DetachedPreflightTests(unittest.TestCase):
def test_removing_a_mount_then_updating_accepts_the_recorded_unused_disk(self):
saved,config=installation()
saved['deployment']['mounts']=[{'type':'managed-volume','source':'local-zfs','size_gb':1,
'container_path':'/extra','backup':True,'read_only':False}]
config+=b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n'
saved['observed']['config']=config.decode()
candidate=copy.deepcopy(saved);candidate['deployment']['mounts']=[]
self.assertEqual(native.plan(saved,candidate,config)['delete'],['mp4'])
detached=config.replace(b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n',b'unused0: local-zfs:subvol-115-disk-1\n')
candidate['observed'].update(config=detached.decode(),config_sha256=transaction.sha(detached))
cfg,_,_=transaction.preflight(candidate,copy.deepcopy(candidate),detached)
self.assertIn('unused0',cfg)
def test_unrecorded_detached_disks_are_not_adopted_silently(self):
saved,config=installation()
with self.assertRaisesRegex(ValueError,'disks do not match|discard those changes'):
transaction.preflight(saved,copy.deepcopy(saved),config+b'unused0: local-zfs:subvol-115-disk-1\n')
def test_invalid_duplicate_or_mounted_unused_disks_are_rejected(self):
for extra in (b'unused0: /etc/passwd\n',b'unused0: local-zfs:subvol-115-disk-0\n',
b'unused0: local-zfs:subvol-115-disk-1\nunused1: local-zfs:subvol-115-disk-1\n'):
saved,config=installation();config+=extra;saved['observed']['config']=config.decode()
with self.assertRaisesRegex(ValueError,'disks do not match'):transaction.preflight(saved,copy.deepcopy(saved),config)
class DetachedTransferTests(unittest.TestCase):
def setUp(self):
saved,config=installation()
self.configs={115:config+b'unused0: local-zfs:subvol-115-disk-1\nunused2: local-zfs:subvol-115-disk-2\n',
900:b'description: proxmenux-transaction=test-tx\nrootfs: local-zfs:subvol-900-disk-0\n'}
self.state={'vmid':115,'stage':900,'id':'test-tx','record':saved,
'retained_unused':transaction.unused_volumes(self.configs[115])}
self.calls=[]
def owned(vmid,marker):
self.assertIn(marker.encode(),self.configs[vmid]);return self.configs[vmid]
def run(*args):
self.calls.append(args);self.assertEqual(args[:2],('pct','move-volume'))
source,key,target=int(args[2]),args[3],int(args[5]);self.assertEqual(key,args[7])
line=next(line for line in self.configs[source].splitlines(keepends=True) if line.startswith((key+': ').encode()))
self.configs[source]=self.configs[source].replace(line,b'')
self.configs[target]+=line.replace(('subvol-'+str(source)+'-').encode(),('subvol-'+str(target)+'-').encode())
for p in (patch.object(transaction,'owned',side_effect=owned),patch.object(transaction,'run',side_effect=run)):
p.start();self.addCleanup(p.stop)
def test_unused_disks_are_parked_and_returned_without_freeing_any_volume(self):
transaction.move_retained_unused(self.state,True)
self.assertEqual(transaction.unused_volumes(self.configs[115]),{})
self.assertEqual(set(transaction.unused_volumes(self.configs[900])),{'unused0','unused2'})
transaction.move_retained_unused(self.state,False)
self.assertEqual(transaction.unused_volumes(self.configs[115]),self.state['retained_unused'])
self.assertEqual(transaction.unused_volumes(self.configs[900]),{})
self.assertEqual(len(self.calls),4)
def test_an_interrupted_transfer_can_be_retried_in_both_directions(self):
original=transaction.run.side_effect
def interrupt(*args):
original(*args);raise RuntimeError('interrupted after reassignment')
with patch.object(transaction,'run',side_effect=interrupt):
with self.assertRaises(RuntimeError):transaction.move_retained_unused(self.state,True)
transaction.move_retained_unused(self.state,True)
with patch.object(transaction,'run',side_effect=interrupt):
with self.assertRaises(RuntimeError):transaction.move_retained_unused(self.state,False)
transaction.move_retained_unused(self.state,False)
self.assertEqual(transaction.unused_volumes(self.configs[115]),self.state['retained_unused'])
def test_unrelated_unused_disks_are_not_moved(self):
self.configs[115]+=b'unused3: local-zfs:subvol-115-disk-3\n'
with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,True)
self.assertEqual(self.calls,[])
def test_a_missing_disk_or_target_collision_blocks_the_transfer(self):
del self.state['retained_unused']['unused2']
self.configs[115]=self.configs[115].replace(b'unused2: local-zfs:subvol-115-disk-2\n',b'')
self.configs[900]+=b'unused0: local-zfs:subvol-900-disk-9\n'
with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,True)
self.configs[115]=self.configs[115].replace(b'unused0: local-zfs:subvol-115-disk-1\n',b'')
self.configs[900]=self.configs[900].replace(b'unused0: local-zfs:subvol-900-disk-9\n',b'')
with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,False)
if __name__=='__main__':unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""Exercise the actual update and recovery orchestration with a simulated PVE API.
Unused volumes must leave the old CT before destroy/restore and return before
validation/commit, including an interruption during volume reassignment.
"""
import copy
import json
from pathlib import Path
import re
import socket
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'remote'))
import oci_instances as instances
import oci_instance_transaction as transaction
from test_modify_review import installation
from test_modify_image_reuse import NEW
class UpdateRetainedDiskTests(unittest.TestCase):
def setUp(self):
temp=tempfile.TemporaryDirectory();self.addCleanup(temp.cleanup)
self.root=Path(temp.name)/'instances'
self.archive=Path(temp.name)/'image.tar';self.archive.write_bytes(b'verified fixture')
self.backup=Path(temp.name)/'backup.tar.zst';self.backup.write_bytes(b'verified backup')
self.saved,config=installation()
self.base_config=config
config+=b'unused0: local-zfs:subvol-115-disk-1\nunused2: local-zfs:subvol-115-disk-2\n'
self.saved['observed'].update(config=config.decode(),config_sha256=transaction.sha(config))
self.saved['observed']['archive_path']=str(self.archive)
self.image=copy.deepcopy(self.saved['observed']['image']);self.image['manifest_digest']=NEW
self.configs={115:config};self.commands=[];self.fail_install=False;self.interrupt_return=False
instances.write(instances.location(self.root,115),self.saved)
def config_line(vmid,key,value):
before=self.configs[vmid]
result=b''.join(line for line in before.splitlines(keepends=True) if not line.startswith((key+': ').encode()))
self.configs[vmid]=result+(key+': '+value+'\n').encode()
def run(*args):
self.commands.append(args)
if args[0]=='zstd':
self.assertEqual(args[1:],('-t',str(self.backup)))
return b''
if args[:2]==('pvesh','get'):
if args[2]=='/cluster/nextid':return b'900'
return json.dumps([{'vmid':vmid,'type':'lxc','node':socket.gethostname().split('.')[0]}
for vmid in self.configs]).encode() if args[2]=='/cluster/resources' else b'[]'
self.assertEqual(args[0],'pct',args)
action,vmid=args[1],int(args[2])
if action=='config':return self.configs[vmid]
if action=='status':return b'status: running'
if action in ('start','shutdown','stop'):return b''
if action=='create':
marker=args[args.index('--description')+1]
self.configs[vmid]=f'description: {marker}\nrootfs: local-zfs:subvol-{vmid}-disk-0\n'.encode()
elif action=='destroy':
self.assertFalse(transaction.unused_volumes(self.configs[vmid]),'would destroy retained data')
self.assertFalse(transaction.mounts(self.configs[vmid]),'would destroy attached data')
del self.configs[vmid]
elif action=='set':
for index in range(3,len(args),2):config_line(vmid,args[index][2:],args[index+1])
elif action=='move-volume':
key,target,target_key=args[3],int(args[5]),args[7]
volume=transaction.unused_volumes(self.configs[vmid])[key]
line=(key+': '+volume+'\n').encode()
self.configs[vmid]=self.configs[vmid].replace(line,b'')
volume=volume.replace('subvol-'+str(vmid)+'-','subvol-'+str(target)+'-')
config_line(target,target_key,volume)
if self.interrupt_return and target==115:
self.interrupt_return=False
raise RuntimeError('interrupted return after PVE reassignment')
elif action=='restore':
# vzdump does not contain detached disks; restore skips unusedN.
self.assertFalse(transaction.unused_volumes(self.configs.get(vmid,b'')))
self.configs[vmid]=self.base_config
else:self.fail('Unexpected PVE command: '+str(args))
return b''
def install(root,journal,state,progress=None):
if self.fail_install:raise RuntimeError('installer failed after old rootfs was removed')
self.configs[state['vmid']]=self.base_config
def observe(vmid,identity,archive,digest,image=None):
observed=copy.deepcopy(self.saved['observed'])
observed.update(config=self.configs[vmid].decode(),config_sha256=transaction.sha(self.configs[vmid]),
image=copy.deepcopy(image or self.image),resolved_registry_digest=digest)
return observed
original_exists=Path.exists
def exists(path):
match=re.fullmatch(r'/etc/pve/lxc/([0-9]+)\.conf',str(path))
return int(match[1]) in self.configs if match else original_exists(path)
patches=[patch.object(transaction,'run',side_effect=run),patch.object(transaction,'open_log'),
patch.object(transaction,'log'),patch.object(transaction,'stop'),
patch.object(transaction,'check_archive'),patch.object(transaction,'image_from_archive',return_value=self.image),
patch.object(transaction,'verified_backup',return_value=self.backup),
patch.object(transaction,'install_candidate',side_effect=install),
patch.object(transaction,'healthcheck',return_value='running'),
patch.object(transaction,'candidate_host_sources',return_value={}),
patch.object(transaction,'prune_backups'),patch.object(transaction.image_cache,'prune',return_value=[]),
patch.object(transaction,'cleanup_restored_format_dirs'),patch.object(transaction.runtime_settings,'restore'),
patch.object(transaction.work_backup,'locate',side_effect=lambda directory,*a:directory),
patch.object(instances,'observe',side_effect=observe),patch.object(Path,'exists',exists)]
for p in patches:p.start();self.addCleanup(p.stop)
self.addCleanup(transaction._run.update,journal=None)
def apply(self,**kwargs):return transaction.apply(self.root,115,self.archive,'update',registry_digest=NEW,**kwargs)
def journal(self):return next(instances.location(self.root,115).parent.glob('transactions/*/transaction.json'))
def assert_retained(self):
self.assertEqual(transaction.unused_volumes(self.configs[115]),{
'unused0':'local-zfs:subvol-115-disk-1','unused2':'local-zfs:subvol-115-disk-2'})
self.assertNotIn(900,self.configs)
self.assertFalse(any(args[:2]==('pct','set') and '--delete' in args for args in self.commands))
def test_update_keeps_detached_disks_and_the_next_update_preflight_accepts_them(self):
self.apply();self.assert_retained()
saved=instances.read(self.root,115)
self.assertEqual(saved['status'],'installed')
self.assertEqual(saved['observed']['image']['manifest_digest'],NEW)
transaction.preflight(saved,copy.deepcopy(saved),self.configs[115])
self.assertEqual(json.loads(self.journal().read_text())['phase'],'committed')
def test_failed_install_restores_both_backup_and_detached_disks(self):
self.fail_install=True
with self.assertRaisesRegex(RuntimeError,'installer failed'):self.apply()
transaction.recover(self.root,self.journal())
self.assert_retained()
self.assertEqual(instances.read(self.root,115)['observed']['image'],self.saved['observed']['image'])
self.assertEqual(json.loads(self.journal().read_text())['phase'],'rolled-back')
def test_recovery_after_parking_data_returns_the_original_unused_disks(self):
with self.assertRaisesRegex(RuntimeError,'Lab interruption'):self.apply(interrupt_after='data-parked')
transaction.recover(self.root,self.journal());self.assert_retained()
def test_recovery_handles_an_interruption_while_returning_unused_disks(self):
self.interrupt_return=True
with self.assertRaisesRegex(RuntimeError,'interrupted return'):self.apply()
transaction.recover(self.root,self.journal());self.assert_retained()
def test_failed_runtime_check_recovers_disks_already_returned_to_the_candidate(self):
with patch.object(transaction.gpu_devices,'validate_runtime',side_effect=[RuntimeError('invalid runtime'), None]):
with self.assertRaisesRegex(RuntimeError,'invalid runtime'):self.apply()
transaction.recover(self.root,self.journal())
self.assert_retained()
if __name__=='__main__':unittest.main()
+9 -5
View File
@@ -236,14 +236,18 @@ class NativeApplyTests(unittest.TestCase):
update.update(115,proposal={'operation':'recreate','candidate':self.candidate})
self.assertEqual(instances.read(self.root,115)['deployment']['environment'],self.candidate['deployment']['environment'])
def test_unsupported_same_image_adaptation_never_silently_recreates(self):
def test_unsupported_same_image_adaptation_uses_the_existing_recreation_flow(self):
self.candidate['deployment']['post_start_configurations']=[{'id':'unsupported-adapter'}]
with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
patch.object(native.transaction,'external_changes',return_value={}), \
patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \
patch.object(native.transaction,'apply',side_effect=AssertionError('recreation')):
with self.assertRaisesRegex(ValueError,'application-specific adaptation'):
update.update(115,proposal={'operation':'recreate','candidate':self.candidate})
patch.object(update,'resolve_archive',return_value=(Path('/verified.tar'),'sha256:test')) as resolve, \
patch.object(native.transaction,'apply') as recreate, \
patch.object(native,'apply',side_effect=AssertionError('partial native edit')), \
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
update.update(115,proposal={'operation':'recreate','candidate':self.candidate})
resolve.assert_called_once()
recreate.assert_called_once()
self.assertEqual(recreate.call_args.args[3],'recreate')
def test_optional_backup_does_not_force_recreation(self):
with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \