fix(oci): carry verified stack metadata into native backups

This commit is contained in:
Jonatan
2026-10-28 09:14:11 +01:00
parent 6a30d92ba7
commit e587aa7d79
14 changed files with 220 additions and 3 deletions
+1
View File
@@ -6932,6 +6932,7 @@
"The build could not be checked beforehand; continuing without that check.": "Der Build konnte vorher nicht überprüft werden;ohne diese Prüfung weitermachen.",
"The cached image does not match the current digest": "Das zwischengespeicherte Bild entspricht nicht dem aktuellen Digest",
"The cached image is damaged; it will be downloaded again.": "Das zwischengespeicherte Bild ist beschädigt; es wird erneut heruntergeladen.",
"The carried record could not be verified before the backup": "Der im Container gespeicherte Datensatz konnte vor der Sicherung nicht überprüft werden",
"The changes need the application installer": "Die Änderungen erfordern das Installationsprogramm der Anwendung",
"The character device does not exist:": "Das Zeichengerät existiert nicht:",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Der Befehl fragt nach einem Passwort, das nicht wiederholt wird. Nach dem Erstellen der Benutzer fragt die Weboberfläche nach einem Benutzernamen und einem Passwort.",
+1
View File
@@ -6915,6 +6915,7 @@
"The build could not be checked beforehand; continuing without that check.": "La compilación no se pudo comprobar de antemano; se continúa sin esa comprobación.",
"The cached image does not match the current digest": "La imagen en caché no coincide con el digest actual",
"The cached image is damaged; it will be downloaded again.": "La imagen en caché está dañada; se descargará de nuevo.",
"The carried record could not be verified before the backup": "No se ha podido verificar la copia del registro dentro del contenedor antes de la copia de seguridad",
"The changes need the application installer": "Los cambios requieren el instalador de la aplicación",
"The character device does not exist:": "El dispositivo de carácter no existe:",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "El comando pide una contraseña que no se muestra al escribirla. Tras crear los usuarios, la interfaz web pide un nombre de usuario y una contraseña.",
+1
View File
@@ -6931,6 +6931,7 @@
"The build could not be checked beforehand; continuing without that check.": "La build n'a pas pu être vérifiée au préalable ;continuer sans cette vérification.",
"The cached image does not match the current digest": "L'image mise en cache ne correspond pas au digest actuel",
"The cached image is damaged; it will be downloaded again.": "L'image mise en cache est endommagée ; elle sera à nouveau téléchargée.",
"The carried record could not be verified before the backup": "Les métadonnées conservées dans le conteneur n’ont pas pu être vérifiées avant la sauvegarde",
"The changes need the application installer": "Ces modifications nécessitent le programme d’installation de l’application",
"The character device does not exist:": "Le dispositif de caractère n'existe pas :",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "La commande demande un mot de passe qui n'est pas repris. Après la création des utilisateurs, l'interface web demande un nom d'utilisateur et un mot de passe.",
+1
View File
@@ -6932,6 +6932,7 @@
"The build could not be checked beforehand; continuing without that check.": "Non è stato possibile verificare la build in anticipo; si prosegue senza tale verifica.",
"The cached image does not match the current digest": "L'immagine nella cache non corrisponde al digest corrente",
"The cached image is damaged; it will be downloaded again.": "L'immagine nella cache è danneggiata; verrà scaricata nuovamente.",
"The carried record could not be verified before the backup": "Impossibile verificare la copia del registro nel contenitore prima del backup",
"The changes need the application installer": "Le modifiche richiedono il programma di installazione dell’applicazione",
"The character device does not exist:": "Il dispositivo a caratteri non esiste:",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Il comando richiede una password non visualizzata durante la digitazione. Dopo la creazione degli utenti, l’interfaccia web richiede nome utente e password.",
+1
View File
@@ -6932,6 +6932,7 @@
"The build could not be checked beforehand; continuing without that check.": "A compilação não pôde ser verificada antecipadamente;continuando sem essa verificação.",
"The cached image does not match the current digest": "A imagem em cache não corresponde à digest atual",
"The cached image is damaged; it will be downloaded again.": "A imagem em cache está danificada; será transferida novamente.",
"The carried record could not be verified before the backup": "Não foi possível verificar a cópia do registo no contentor antes da cópia de segurança",
"The changes need the application installer": "As alterações requerem o instalador da aplicação",
"The character device does not exist:": "O dispositivo de caracteres não existe:",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "O comando pede uma senha que não seja ecoada. Depois de criar os usuários, a interface web pede um nome de usuário e uma senha.",
+1
View File
@@ -6933,6 +6933,7 @@
"The build could not be checked beforehand; continuing without that check.": "Zostavenie nebolo možné vopred skontrolovať;pokračovanie bez tejto kontroly.",
"The cached image does not match the current digest": "Comment",
"The cached image is damaged; it will be downloaded again.": "Obraz vo vyrovnávacej pamäti je poškodený; stiahne sa znova.",
"The carried record could not be verified before the backup": "Pred zálohovaním sa nepodarilo overiť kópiu záznamu uloženú v kontajneri",
"The changes need the application installer": "Zmeny vyžadujú inštalačný program aplikácie",
"The character device does not exist:": "Znakové zariadenie neexistuje:",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Príkaz sa opýta na heslo, ktoré sa pri písaní nezobrazuje. Po vytvorení používateľov bude webové rozhranie vyžadovať používateľské meno a heslo.",
+1
View File
@@ -6932,6 +6932,7 @@
"The build could not be checked beforehand; continuing without that check.": "Bygget kunde inte kontrolleras i förväg;fortsätter utan den kontrollen.",
"The cached image does not match the current digest": "Den cachade bilden matchar inte den nuvarande smältningen",
"The cached image is damaged; it will be downloaded again.": "Den cachade bilden är skadad; den kommer att laddas ner igen.",
"The carried record could not be verified before the backup": "Kopian av installationsposten i containern kunde inte verifieras före säkerhetskopieringen",
"The changes need the application installer": "Ändringarna kräver programmets installationsverktyg",
"The character device does not exist:": "Karaktärenheten existerar inte:",
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Kommandot begär ett lösenord som inte echoed. Efter att ha skapat användarna ber webbgränssnittet om ett användarnamn och ett lösenord.",
+5
View File
@@ -256,6 +256,11 @@ The native exit-status monitor currently accepts LXC 7.0.0, the verified protoco
version. Other versions refuse one-shot execution rather than treating a stopped
container as proof of success.
Before each native stack backup, the engine writes the validated original
installation record into every stopped member and reads it back. A missing or
unverifiable copy aborts the backup, so database and worker archives do not
silently lose the metadata needed to register the restored stack.
The bridges are local to the installation node; they are not a cluster SDN.
Unsupported network and runtime semantics are reported before installation.
Support for several services does not imply complete Docker Compose compatibility.
+71 -1
View File
@@ -31,7 +31,7 @@ import sys
import uuid
import oci_instances as instances
from oci_installation_state import sha
from oci_installation_state import sha, parse_config
from oci_ui import translate
DIRECTORY = '.proxmenux'
@@ -40,6 +40,7 @@ KIND = 'proxmenux.oci-carried-record'
STAMP = 'carried.sha256'
CONFIG_CACHE = 'carried-config.json'
LOCAL_CONFIGS = Path('/etc/pve/lxc')
ROOTFS = Path('/var/lib/lxc')
LIMIT = 16 * 1024 * 1024
STACK_CONTRACT = '/etc/pve/priv/proxmenux-stack-{}.json'
NVIDIA_HOOK = re.compile(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh')
@@ -400,6 +401,75 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False):
return outcome
def carry_backup(root, vmid, original_record, validated_config_text):
"""Require a verified original record inside a stopped CT before vzdump.
A coordinated transaction has already marked the host registry pending.
The explicit original is the validated plan record, never that pending
record or an untrusted copy from inside the guest. Unlike best-effort
carry(), any missing copy, configuration drift or I/O failure aborts backup.
"""
failure = translate('The carried record could not be verified before the backup')
if isinstance(validated_config_text, bytes):
validated_config_text = validated_config_text.decode('utf-8')
if (not isinstance(original_record, dict) or original_record.get('vmid') != vmid
or original_record.get('status') != 'installed'
or original_record.get('pending_transaction') or original_record.get('pending_stack_transaction')
or not isinstance(validated_config_text, str)):
raise ValueError(failure)
identity = original_record.get('installation_id')
if not identity or instances.identity(validated_config_text.encode()) != identity:
raise ValueError(failure)
def checked_stopped():
status = _run('pct', 'status', str(int(vmid)))
if status.returncode or status.stdout.strip() != 'status: stopped':
raise ValueError(failure)
def checked_configuration():
result = _run('pct', 'config', str(int(vmid)))
if result.returncode or instances.identity(result.stdout.encode()) != identity:
raise ValueError(failure)
def projection(text):
if re.search(r'^\[[^]\r\n]+\]\s*$', text, re.MULTILINE):
raise ValueError(failure)
values = parse_config(text.encode())
# The identity is checked independently; notes and maintenance
# locks do not change the image, mounts, network, user or devices.
values.pop('description', None)
lock = values.pop('lock', None)
if lock not in (None, 'backup', 'mounted'):
raise ValueError(failure)
return values
if projection(result.stdout) != projection(validated_config_text):
raise ValueError(failure)
return result.stdout
checked_stopped()
config = checked_configuration()
if _host_directory_missing(vmid) or _run('pct', 'mount', str(int(vmid))).returncode:
raise ValueError(failure)
try:
checked_stopped()
checked_configuration()
rootfs = ROOTFS / str(int(vmid)) / 'rootfs'
value = bundle(original_record, config)
value['saved_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
value['generation'] = uuid.uuid4().hex
write_copy(rootfs, value, mapped_root(config))
copied = read_copy(rootfs)
if copied != value or digest(copied) != digest(value):
raise ValueError(failure)
checked_stopped()
checked_configuration()
finally:
if _run('pct', 'unmount', str(int(vmid))).returncode:
raise ValueError(failure)
instances.write(instances.location(root, vmid).parent / STAMP,
{'digest': digest(value), 'generation': value['generation']})
return {'status': 'carried', 'generation': value['generation'], 'digest': digest(value)}
def registered(root):
if not root.is_dir():
return []
+3
View File
@@ -14,6 +14,7 @@ import tempfile
import uuid
import oci_image_cache as image_cache
import oci_carried_record as carried
import oci_compose_jobs as compose_jobs
import oci_instances as instances
import oci_instance_transaction as member_tx
@@ -417,6 +418,8 @@ class NativeAdapter:
f"{self.records[self.plan['primary_vmid']]['deployment'].get(work_backup.KEY)}")
directory = base / ('backup-%s' % vmid)
private_directory(directory)
config = member_tx.owned(vmid, identity).decode()
carried.carry_backup(self.root, vmid, self.records[vmid], config)
archive = member_tx.verified_backup(vmid, directory, 'zstd',
translate('The backup of a member could not be identified'))
return {'archive': str(archive), 'sha256': member_tx.filehash(archive)}
+2 -2
View File
@@ -1598,7 +1598,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
shutil.copy2(project_root / 'remote' / 'oci_image_mountpoints.py', temporary / 'oci_image_mountpoints.py')
for helper in ('oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1635,7 +1635,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
+86
View File
@@ -0,0 +1,86 @@
"""A backup must contain its validated original metadata, despite a pending registry."""
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_carried_record as carried
VMID = 4801
IDENTITY = '488ed3cb-1145-477a-b90f-c46777d69fb2'
CONFIG = ('description: <!-- proxmenux-instance='+IDENTITY+' -->\n'
'rootfs: local-zfs:subvol-4801-disk-0,size=8G\n'
'unprivileged: 1\nlxc.init.uid: 999\n'
'net0: name=eth0,bridge=vmbr10,ip=10.77.0.31/24\n')
ORIGINAL = {'vmid': VMID, 'installation_id': IDENTITY, 'status': 'installed',
'deployment': {}, 'observed': {'config': CONFIG}}
class BackupCarriedRecordTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(); self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)/'registry'; self.rootfs_base = Path(temporary.name)/'lxc'
self.rootfs = self.rootfs_base/str(VMID)/'rootfs'; self.rootfs.mkdir(parents=True)
self.config = CONFIG; self.status = 'stopped'; self.unmount_code = 0; self.commands = []
def run(*args):
self.commands.append(args)
value = self.config if args[1]=='config' else 'status: '+self.status if args[1]=='status' else ''
code = self.unmount_code if args[1]=='unmount' else 0
return SimpleNamespace(returncode=code, stdout=value)
for name, value in [('_run',run),('_host_directory_missing',lambda _:False),
('ROOTFS',self.rootfs_base),('mapped_root',lambda _: (os.getuid(),os.getgid()))]:
context=patch.object(carried,name,value);context.start();self.addCleanup(context.stop)
def carry(self, original=None):
return carried.carry_backup(self.root,VMID,original or ORIGINAL,CONFIG)
def test_pending_host_registry_does_not_replace_the_validated_original(self):
pending=copy.deepcopy(ORIGINAL);pending.update(status='updating',pending_stack_transaction='/transaction')
carried.instances.write(carried.instances.location(self.root,VMID),pending)
result=self.carry()
copy_in_backup=carried.read_copy(self.rootfs)
self.assertEqual(copy_in_backup['record'],ORIGINAL)
self.assertNotIn('pending_stack_transaction',copy_in_backup['record'])
self.assertEqual(result['digest'],carried.digest(copy_in_backup))
self.assertEqual(self.commands.count(('pct','mount',str(VMID))),1)
self.assertEqual(self.commands.count(('pct','unmount',str(VMID))),1)
self.assertEqual(json.loads(carried.instances.location(self.root,VMID).read_text()),pending)
def test_notes_and_backup_lock_changes_do_not_hide_identity(self):
self.config=CONFIG.replace('description: ','description: user notes ')+'lock: backup\n'
self.carry()
self.assertEqual(carried.read_copy(self.rootfs)['record'],ORIGINAL)
def test_runtime_network_mount_user_and_identity_drift_are_refused(self):
for before,after in [('10.77.0.31','10.77.0.99'),('disk-0','disk-1'),('uid: 999','uid: 1000'),(IDENTITY,'11111111-1111-4111-8111-111111111111')]:
with self.subTest(after=after):
self.config=CONFIG.replace(before,after);self.commands=[]
with self.assertRaises(ValueError):self.carry()
self.assertNotIn(('pct','mount',str(VMID)),self.commands)
def test_running_guest_and_pending_original_are_refused(self):
self.status='running'
with self.assertRaises(ValueError):self.carry()
self.status='stopped'
for field,value in [('status','updating'),('pending_transaction','/transaction'),('pending_stack_transaction','/stack')]:
record=dict(ORIGINAL,**{field:value})
with self.subTest(field=field),self.assertRaises(ValueError):self.carry(record)
def test_write_failure_is_not_silenced_and_always_unmounts(self):
with patch.object(carried,'write_copy',side_effect=OSError('disk full')):
with self.assertRaisesRegex(OSError,'disk full'):self.carry()
self.assertIn(('pct','unmount',str(VMID)),self.commands)
def test_roundtrip_mismatch_and_unmount_failure_abort_before_success_stamp(self):
with patch.object(carried,'read_copy',return_value=None):
with self.assertRaises(ValueError):self.carry()
self.assertFalse((carried.instances.location(self.root,VMID).parent/carried.STAMP).exists())
self.unmount_code=1
with self.assertRaises(ValueError):self.carry()
if __name__=='__main__':unittest.main()
@@ -41,6 +41,7 @@ class JobInstallIntegrationTests(unittest.TestCase):
def inspect(command,*args):
staged=Path(command[1]).parent/'oci_compose_jobs.py'
self.assertEqual(staged.read_bytes(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
self.assertEqual((staged.parent/'oci_carried_record.py').read_bytes(),(ROOT/'remote/oci_carried_record.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'local',True)
@@ -52,6 +53,7 @@ class JobInstallIntegrationTests(unittest.TestCase):
self.assertEqual(command[0],'ssh')
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:
self.assertEqual(archive.extractfile('oci_compose_jobs.py').read(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
self.assertEqual(archive.extractfile('oci_carried_record.py').read(),(ROOT/'remote/oci_carried_record.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'test-node',True)
@@ -0,0 +1,44 @@
"""A coordinated native backup must contain its verified, original restore recipe."""
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'remote'))
import oci_stack_native as native
class StackBackupCarriedMetadataTests(unittest.TestCase):
def adapter(self, folder):
adapter=object.__new__(native.NativeAdapter)
adapter.root=Path('/unused/registry')
adapter.journal=Path(folder)/'transaction.json'
adapter.plan={'primary_vmid':4901}
adapter.records={4901:{'vmid':4901,'installation_id':'original','status':'installed'}}
return adapter
def test_restore_metadata_is_verified_before_archive_creation(self):
with tempfile.TemporaryDirectory() as folder:
adapter=self.adapter(folder);events=[]
archive=Path(folder)/'archive.tar.zst'
with patch.object(adapter,'validate'),patch.object(adapter,'work_base',return_value=Path(folder)), \
patch.object(native.member_tx,'owned',return_value=b'validated original config') as owned, \
patch.object(native.carried,'carry_backup',side_effect=lambda *args:events.append(('carry',args)),create=True), \
patch.object(native.member_tx,'verified_backup',side_effect=lambda *args:events.append(('backup',args)) or archive), \
patch.object(native.member_tx,'filehash',return_value='verified-sha'):
result=adapter.backup(4901,'original')
owned.assert_called_once_with(4901,'original')
self.assertEqual([event[0] for event in events],['carry','backup'])
self.assertEqual(events[0][1],(adapter.root,4901,adapter.records[4901],'validated original config'))
self.assertEqual(result,{'archive':str(archive),'sha256':'verified-sha'})
def test_missing_restore_metadata_prevents_archive_creation(self):
with tempfile.TemporaryDirectory() as folder:
adapter=self.adapter(folder)
with patch.object(adapter,'validate'),patch.object(adapter,'work_base',return_value=Path(folder)), \
patch.object(native.member_tx,'owned',return_value=b'validated original config'), \
patch.object(native.carried,'carry_backup',side_effect=ValueError('metadata not verified'),create=True), \
patch.object(native.member_tx,'verified_backup') as backup:
with self.assertRaisesRegex(ValueError,'metadata not verified'):
adapter.backup(4901,'original')
backup.assert_not_called()