mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix(oci): carry verified stack metadata into native backups
This commit is contained in:
@@ -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