mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix(oci): carry verified stack metadata into native backups
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user