From e587aa7d79de2c9cf4df3d9f6f0c5cb888f1abec Mon Sep 17 00:00:00 2001 From: Jonatan Date: Wed, 28 Oct 2026 09:14:11 +0100 Subject: [PATCH] fix(oci): carry verified stack metadata into native backups --- lang/de.json | 1 + lang/es.json | 1 + lang/fr.json | 1 + lang/it.json | 1 + lang/pt.json | 1 + lang/sk.json | 1 + lang/sv.json | 1 + oci/README.md | 5 ++ oci/remote/oci_carried_record.py | 72 +++++++++++++++- oci/remote/oci_stack_native.py | 3 + oci/src/proxmenux_oci/installer.py | 4 +- oci/tests/test_backup_carried_record.py | 86 +++++++++++++++++++ oci/tests/test_compose_job_integration.py | 2 + .../test_stack_backup_carried_metadata.py | 44 ++++++++++ 14 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 oci/tests/test_backup_carried_record.py create mode 100644 oci/tests/test_stack_backup_carried_metadata.py diff --git a/lang/de.json b/lang/de.json index 55d06fb2..0a115777 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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.", diff --git a/lang/es.json b/lang/es.json index b18ea70f..0a9891f6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/lang/fr.json b/lang/fr.json index 8837aca6..c4df7a33 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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.", diff --git a/lang/it.json b/lang/it.json index d9b98635..b8c8e567 100644 --- a/lang/it.json +++ b/lang/it.json @@ -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.", diff --git a/lang/pt.json b/lang/pt.json index a9f81e63..7da77a68 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -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.", diff --git a/lang/sk.json b/lang/sk.json index 9b50802e..5f30bfae 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -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.", diff --git a/lang/sv.json b/lang/sv.json index 2bb6d7e2..052e6cc6 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -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.", diff --git a/oci/README.md b/oci/README.md index 030637fc..6e512cb8 100644 --- a/oci/README.md +++ b/oci/README.md @@ -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. diff --git a/oci/remote/oci_carried_record.py b/oci/remote/oci_carried_record.py index 14375ef5..c5cb2ca6 100644 --- a/oci/remote/oci_carried_record.py +++ b/oci/remote/oci_carried_record.py @@ -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 [] diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py index 47d6ae26..c8ef8f72 100644 --- a/oci/remote/oci_stack_native.py +++ b/oci/remote/oci_stack_native.py @@ -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)} diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 332b994b..95bfecbb 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -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') diff --git a/oci/tests/test_backup_carried_record.py b/oci/tests/test_backup_carried_record.py new file mode 100644 index 00000000..f0da7c0c --- /dev/null +++ b/oci/tests/test_backup_carried_record.py @@ -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: \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() diff --git a/oci/tests/test_compose_job_integration.py b/oci/tests/test_compose_job_integration.py index 4fa84a8f..866888cb 100644 --- a/oci/tests/test_compose_job_integration.py +++ b/oci/tests/test_compose_job_integration.py @@ -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) diff --git a/oci/tests/test_stack_backup_carried_metadata.py b/oci/tests/test_stack_backup_carried_metadata.py new file mode 100644 index 00000000..02cc3558 --- /dev/null +++ b/oci/tests/test_stack_backup_carried_metadata.py @@ -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()