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
+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()