mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Preserve Modify fallback and detached disks after native edits
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()), \
|
||||
|
||||
Reference in New Issue
Block a user