Add signed passive runtime updates and independent crash recovery
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import warnings
|
||||
import zipfile
|
||||
|
||||
ENGINE = Path(__file__).resolve().parents[1] / 'hydra/update_engine.py'
|
||||
|
||||
def engine():
|
||||
spec = importlib.util.spec_from_file_location('update_engine', ENGINE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
class UpdateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.root = self.base / 'root'
|
||||
for name in ('usr/lib/hydra/hydra', 'usr/share/hydra', 'etc', 'var/lib', 'home/live/VMs'):
|
||||
(self.root / name).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / 'usr/share/hydra/build-manifest.json').write_text('{}')
|
||||
self.target = self.root / 'usr/lib/hydra/hydra/cli.py'
|
||||
self.target.write_bytes(b'old runtime\n')
|
||||
self.target.chmod(0o640)
|
||||
self.vm = self.root / 'home/live/VMs/disk.qcow2'
|
||||
self.vm.write_bytes(b'guest disk unchanged')
|
||||
self.key = self.base / 'private.pem'
|
||||
self.pub = self.base / 'public.pem'
|
||||
subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(self.key)], check=True, capture_output=True)
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(self.key), '-pubout', '-out', str(self.pub)], check=True, capture_output=True)
|
||||
self.artifacts = self.base / 'artifacts'
|
||||
self.artifacts.mkdir()
|
||||
|
||||
def package(self, *, files=None, sequence=1, entries=None, raw=None):
|
||||
files = files or {'usr/lib/hydra/hydra/cli.py': b'new runtime\n', 'etc/motd': b'Welcome\n'}
|
||||
with zipfile.ZipFile(self.artifacts / 'runtime.zip', 'w') as archive:
|
||||
for name, data in (entries or list(files.items())):
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
||||
archive.writestr(info, data)
|
||||
data = (self.artifacts / 'runtime.zip').read_bytes()
|
||||
manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable',
|
||||
'architecture': 'x86_64', 'sequence': sequence, 'version': '1.2.3',
|
||||
'source_commit': 'a' * 40,
|
||||
'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()},
|
||||
'files': {name: {'size': len(content), 'sha256': hashlib.sha256(content).hexdigest(), 'mode': 0o644}
|
||||
for name, content in files.items()}}
|
||||
self.sign(raw if raw is not None else json.dumps(manifest).encode())
|
||||
return manifest
|
||||
|
||||
def sign(self, raw):
|
||||
(self.artifacts / 'manifest.json').write_bytes(raw)
|
||||
subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(self.key), '-in',
|
||||
str(self.artifacts / 'manifest.json'), '-out', str(self.artifacts / 'manifest.sig')], check=True, capture_output=True)
|
||||
|
||||
def updater(self):
|
||||
return engine().Updater(self.root, self.pub)
|
||||
|
||||
def test_signed_apply_and_rollback_restore_bytes_mode_absence_and_keep_replay_floor(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
result = updater.apply(self.artifacts)
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
updater.rollback(result['transaction'])
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o640)
|
||||
self.assertFalse((self.root / 'etc/motd').exists())
|
||||
self.assertEqual(updater.status()['highest_sequence'], 1)
|
||||
with self.assertRaisesRegex(ValueError, 'sequence|replay'):
|
||||
updater.apply(self.artifacts)
|
||||
|
||||
def test_tampered_signature_payload_duplicate_json_and_forbidden_paths_fail_before_state(self):
|
||||
for kind in ('signature', 'payload', 'duplicate_json', 'forbidden', 'duplicate_zip', 'boolean_sequence'):
|
||||
with self.subTest(kind=kind):
|
||||
self.package()
|
||||
if kind == 'signature':
|
||||
(self.artifacts / 'manifest.json').write_bytes((self.artifacts / 'manifest.json').read_bytes() + b' ')
|
||||
elif kind == 'payload':
|
||||
with (self.artifacts / 'runtime.zip').open('ab') as stream:
|
||||
stream.write(b'changed')
|
||||
elif kind == 'duplicate_json':
|
||||
raw = (self.artifacts / 'manifest.json').read_bytes().replace(b'"format": 1', b'"format": 1, "format": 1')
|
||||
self.sign(raw)
|
||||
elif kind == 'forbidden':
|
||||
self.package(files={'home/live/VMs/disk.qcow2': b'evil'})
|
||||
elif kind == 'boolean_sequence':
|
||||
self.package(sequence=True)
|
||||
else:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
self.package(entries=[('usr/lib/hydra/hydra/cli.py', b'new runtime\n')] * 2)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_symlink_leaf_restored_but_unsafe_parent_refused(self):
|
||||
self.package()
|
||||
self.target.unlink()
|
||||
self.target.symlink_to('/outside/missing')
|
||||
updater = self.updater()
|
||||
result = updater.apply(self.artifacts)
|
||||
updater.rollback(result['transaction'])
|
||||
self.assertEqual(os.readlink(self.target), '/outside/missing')
|
||||
self.target.unlink()
|
||||
self.target.parent.rmdir()
|
||||
self.target.parent.symlink_to(self.base)
|
||||
self.package(sequence=2)
|
||||
with self.assertRaisesRegex(ValueError, 'symlink|parent'):
|
||||
updater.apply(self.artifacts)
|
||||
|
||||
def test_rollback_refuses_modified_runtime_and_corrupt_backup(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
result = updater.apply(self.artifacts)
|
||||
self.target.write_bytes(b'local edit')
|
||||
with self.assertRaisesRegex(ValueError, 'modified'):
|
||||
updater.rollback(result['transaction'])
|
||||
self.target.write_bytes(b'new runtime\n')
|
||||
backups = list((self.root / 'var/lib/hydra-update/transactions' / result['transaction'] / 'backup').iterdir())
|
||||
backups[0].write_bytes(b'corrupt')
|
||||
with self.assertRaisesRegex(ValueError, 'backup'):
|
||||
updater.rollback(result['transaction'])
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
|
||||
def test_rejects_signed_special_archive_types_sizes_traversal_and_engine_updates(self):
|
||||
for kind in ('symlink', 'fifo', 'oversize', 'traversal', 'engine'):
|
||||
with self.subTest(kind=kind):
|
||||
manifest = self.package()
|
||||
if kind in ('traversal', 'engine'):
|
||||
name = '../etc/passwd' if kind == 'traversal' else 'usr/lib/hydra/hydra/update_engine.py'
|
||||
self.package(files={name: b'bad'})
|
||||
elif kind == 'oversize':
|
||||
manifest['files']['usr/lib/hydra/hydra/cli.py']['size'] = 5 * 1024 * 1024
|
||||
self.sign(json.dumps(manifest).encode())
|
||||
else:
|
||||
with zipfile.ZipFile(self.artifacts / 'runtime.zip', 'w') as archive:
|
||||
for name, content in {'usr/lib/hydra/hydra/cli.py': b'new runtime\n', 'etc/motd': b'Welcome\n'}.items():
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.create_system = 3
|
||||
info.external_attr = ((stat.S_IFLNK if kind == 'symlink' else stat.S_IFIFO) | 0o644) << 16
|
||||
archive.writestr(info, content)
|
||||
data = (self.artifacts / 'runtime.zip').read_bytes()
|
||||
manifest['archive'].update(size=len(data), sha256=hashlib.sha256(data).hexdigest())
|
||||
self.sign(json.dumps(manifest).encode())
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_fresh_process_recovers_sigkill_during_apply_and_rollback_with_runtime_broken(self):
|
||||
self.package()
|
||||
for operation in ('apply', 'rollback'):
|
||||
with self.subTest(operation=operation):
|
||||
if operation == 'rollback':
|
||||
self.package(sequence=2)
|
||||
transaction = self.updater().apply(self.artifacts)['transaction']
|
||||
call = 'u.rollback(' + repr(transaction) + ')'
|
||||
else:
|
||||
call = 'u.apply(Path(' + repr(str(self.artifacts)) + '))'
|
||||
ready = self.base / ('ready-' + operation)
|
||||
script = '''import os, runpy, signal
|
||||
from pathlib import Path
|
||||
m = runpy.run_path(ENGINE)
|
||||
u = m['Updater'](Path(ROOT), Path(KEY))
|
||||
original = m['atomic_write']
|
||||
def pause(path, *args, **kwargs):
|
||||
original(path, *args, **kwargs)
|
||||
if path == Path(TARGET):
|
||||
Path(READY).touch()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
u.apply.__func__.__globals__['atomic_write'] = pause
|
||||
CALL
|
||||
'''
|
||||
for key, value in [('ENGINE', str(ENGINE)), ('ROOT', str(self.root)), ('KEY', str(self.pub)), ('TARGET', str(self.target)), ('READY', str(ready))]:
|
||||
script = script.replace(key, repr(value))
|
||||
script = script.replace('CALL', call)
|
||||
proc = subprocess.Popen([sys.executable, '-I', '-c', script], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while not ready.exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue(ready.exists(), 'child failed to reach actual mutation')
|
||||
proc.kill()
|
||||
proc.communicate(timeout=5)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
self.assertEqual(len(self.updater().status()['pending']), 1)
|
||||
with self.assertRaisesRegex(ValueError, 'pending'):
|
||||
self.updater().apply(self.artifacts)
|
||||
# No hydra imports: corrupt the normal runtime before standalone recovery.
|
||||
self.target.write_bytes(b'this is invalid Python')
|
||||
result = subprocess.run([sys.executable, '-I', str(ENGINE.parent.parent / 'bin/hydra-update-recover'),
|
||||
'--root', str(self.root), '--public-key', str(self.pub)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertEqual(self.updater().status()['pending'], [])
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
self.assertEqual(self.updater().status()['highest_sequence'], 1 if operation == 'apply' else 2)
|
||||
|
||||
def test_lock_in_separate_process_prevents_mutation(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
with updater.lock():
|
||||
proc = subprocess.run([sys.executable, '-I', str(ENGINE), 'apply', '--root', str(self.root),
|
||||
'--public-key', str(self.pub), '--artifacts', str(self.artifacts)], capture_output=True, text=True)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn('lock', proc.stderr)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
|
||||
def test_only_current_transaction_can_rollback(self):
|
||||
self.package()
|
||||
first = self.updater().apply(self.artifacts)['transaction']
|
||||
self.package(sequence=2)
|
||||
self.updater().apply(self.artifacts)
|
||||
with self.assertRaisesRegex(ValueError, 'latest'):
|
||||
self.updater().rollback(first)
|
||||
|
||||
def test_transport_rejects_origin_changes_credentials_and_oversized_reads(self):
|
||||
module = engine()
|
||||
for url in ('http://git.shanaty.ru/file', 'https://evil.example/file',
|
||||
'https://git.shanaty.ru@evil.example/file', 'https://git.shanaty.ru:443/file',
|
||||
'https://git.shanaty.ru/../file', 'https://git.shanaty.ru/%2e%2e/file'):
|
||||
with self.subTest(url=url), self.assertRaises(ValueError):
|
||||
module.checked_url(url)
|
||||
redirect = module.SafeRedirect()
|
||||
request = __import__('urllib.request', fromlist=['Request']).Request('https://git.shanaty.ru/file')
|
||||
with self.assertRaises(ValueError):
|
||||
redirect.redirect_request(request, None, 302, '', {}, 'https://other.example/file')
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
with patch('urllib.request.OpenerDirector.open', return_value=io.BytesIO(b'x' * 100)):
|
||||
with self.assertRaisesRegex(ValueError, 'size|large'):
|
||||
module.fetch('https://git.shanaty.ru/file', 10)
|
||||
|
||||
def test_empty_releases_and_verified_local_check_do_not_create_state(self):
|
||||
module = engine()
|
||||
from unittest.mock import patch
|
||||
with patch.object(module, 'fetch', return_value=b'[]'):
|
||||
self.assertIsNone(module.discover(self.pub))
|
||||
self.package()
|
||||
result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root),
|
||||
'--public-key', str(self.pub), '--artifacts', str(self.artifacts)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)['available']['sequence'], 1)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_release_builder_is_reproducible_and_signs_passive_payload_only(self):
|
||||
repo = ENGINE.parent.parent
|
||||
output_a = self.base / 'release-a'
|
||||
output_b = self.base / 'release-b'
|
||||
self.key.chmod(0o600)
|
||||
command = [sys.executable, str(repo / 'dev/release.py'), '--source', str(repo),
|
||||
'--private-key', str(self.key), '--version', '1.0.0', '--sequence', '10']
|
||||
for output in (output_a, output_b):
|
||||
result = subprocess.run(command + ['--output', str(output)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
||||
self.assertEqual((output_a / name).read_bytes(), (output_b / name).read_bytes())
|
||||
module = engine()
|
||||
manifest, payload = self.updater().local(output_a)
|
||||
self.assertEqual(manifest['sequence'], 10)
|
||||
self.assertIn('usr/lib/hydra/hydra/cli.py', payload)
|
||||
self.assertFalse(any('/update' in name for name in payload))
|
||||
self.assertNotIn('__main__.py', zipfile.ZipFile(output_a / 'runtime.zip').namelist())
|
||||
result = subprocess.run(command + ['--output', str(output_a)], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.key.chmod(0o644)
|
||||
result = subprocess.run(command + ['--output', str(self.base / 'unsafe-key')], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.base / 'unsafe-key').exists())
|
||||
|
||||
def test_production_api_cannot_override_pinned_key(self):
|
||||
with self.assertRaisesRegex(ValueError, 'key|trust'):
|
||||
engine().Updater(Path('/'), self.pub)
|
||||
|
||||
def test_corrupt_backup_during_pending_recovery_keeps_journal_and_runtime(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
from unittest.mock import patch
|
||||
globals_ = updater.apply.__func__.__globals__
|
||||
original = globals_['atomic_write']
|
||||
def fail(path, *args, **kwargs):
|
||||
if path == self.target:
|
||||
raise OSError('simulated full filesystem')
|
||||
return original(path, *args, **kwargs)
|
||||
with patch.dict(globals_, atomic_write=fail):
|
||||
with self.assertRaises(OSError):
|
||||
updater.apply(self.artifacts)
|
||||
transaction = updater.status()['pending'][0]
|
||||
journal = self.root / 'var/lib/hydra-update/transactions' / transaction / 'journal.json'
|
||||
saved = journal.read_bytes()
|
||||
backup = next((journal.parent / 'backup').iterdir())
|
||||
backup.write_bytes(b'broken backup')
|
||||
with self.assertRaisesRegex(ValueError, 'backup'):
|
||||
updater.recover()
|
||||
self.assertEqual(journal.read_bytes(), saved)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertEqual(updater.status()['pending'], [transaction])
|
||||
|
||||
def test_unsafe_state_directory_and_backup_symlinks_are_rejected(self):
|
||||
self.package()
|
||||
state = self.root / 'var/lib/hydra-update'
|
||||
outside = self.base / 'outside'
|
||||
outside.mkdir()
|
||||
state.symlink_to(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
state.unlink()
|
||||
result = self.updater().apply(self.artifacts)
|
||||
backup = next((state / 'transactions' / result['transaction'] / 'backup').iterdir())
|
||||
backup.unlink()
|
||||
backup.symlink_to(self.vm)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().rollback(result['transaction'])
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
|
||||
def test_discovery_verifies_manifest_before_archive_and_binds_release_tag(self):
|
||||
module = engine()
|
||||
self.package()
|
||||
from unittest.mock import patch
|
||||
urls = {name: 'https://git.shanaty.ru/attachments/' + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}
|
||||
release = {'tag_name': '1.2.3', 'draft': False, 'prerelease': False,
|
||||
'assets': [{'name': name, 'browser_download_url': url} for name, url in urls.items()]}
|
||||
requested = []
|
||||
def fetch(url, limit):
|
||||
requested.append(url)
|
||||
if url == module.RELEASES:
|
||||
return json.dumps([release]).encode()
|
||||
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
||||
with patch.object(module, 'fetch', side_effect=fetch):
|
||||
found = module.discover(self.pub)
|
||||
self.assertEqual(found[0]['version'], '1.2.3')
|
||||
self.assertNotIn(urls['runtime.zip'], requested)
|
||||
release['tag_name'] = 'latest'
|
||||
with self.assertRaisesRegex(ValueError, 'tag'):
|
||||
module.discover(self.pub)
|
||||
release['tag_name'] = '1.2.3'
|
||||
(self.artifacts / 'manifest.json').write_bytes(b'bad manifest')
|
||||
with self.assertRaisesRegex(ValueError, 'signature'):
|
||||
module.discover(self.pub)
|
||||
self.assertNotIn(urls['runtime.zip'], requested)
|
||||
|
||||
def test_missing_or_unwritable_parent_and_special_destination_rejected_before_state(self):
|
||||
self.package()
|
||||
self.target.unlink()
|
||||
os.mkfifo(self.target)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
self.target.unlink()
|
||||
self.target.parent.chmod(0o777)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_signature_wrong_key_and_truncated_archive_fail_without_changes(self):
|
||||
self.package()
|
||||
other = self.base / 'other.pem'
|
||||
otherpub = self.base / 'other.pub'
|
||||
subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(other)], check=True, capture_output=True)
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(other), '-pubout', '-out', str(otherpub)], check=True, capture_output=True)
|
||||
with self.assertRaisesRegex(ValueError, 'signature'):
|
||||
engine().Updater(self.root, otherpub).apply(self.artifacts)
|
||||
data = (self.artifacts / 'runtime.zip').read_bytes()
|
||||
(self.artifacts / 'runtime.zip').write_bytes(data[:20])
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_status_remains_readable_when_private_state_cannot_be_read(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
applied = updater.apply(self.artifacts)
|
||||
state = self.root / 'var/lib/hydra-update'
|
||||
state.chmod(0)
|
||||
try:
|
||||
result = updater.status()
|
||||
self.assertEqual(result['current']['transaction'], applied['transaction'])
|
||||
self.assertEqual(result['highest_sequence'], 1)
|
||||
self.assertEqual(result['pending'], [])
|
||||
finally:
|
||||
state.chmod(0o700)
|
||||
|
||||
def test_recovery_rejects_corrupt_journal_paths_before_touching_runtime(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
transaction = updater.apply(self.artifacts)['transaction']
|
||||
path = self.root / 'var/lib/hydra-update/transactions' / transaction / 'journal.json'
|
||||
journal = json.loads(path.read_text())
|
||||
journal['phase'] = 'prepared'
|
||||
journal['originals']['home/live/VMs/disk.qcow2'] = {'type': 'absent'}
|
||||
journal['files']['home/live/VMs/disk.qcow2'] = {'mode': 0o644, 'size': 1, 'sha256': 'a' * 64}
|
||||
path.write_text(json.dumps(journal))
|
||||
with self.assertRaisesRegex(ValueError, 'backup path'):
|
||||
updater.recover()
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
|
||||
def test_prepared_journal_without_state_is_recoverable_and_reserves_sequence(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
from unittest.mock import patch
|
||||
globals_ = updater.apply.__func__.__globals__
|
||||
original = globals_['write_json']
|
||||
def crash(path, *args, **kwargs):
|
||||
if path == self.root / 'var/lib/hydra-update/state.json':
|
||||
raise OSError('power lost after durable journal before ledger')
|
||||
return original(path, *args, **kwargs)
|
||||
with patch.dict(globals_, write_json=crash):
|
||||
with self.assertRaises(OSError):
|
||||
updater.apply(self.artifacts)
|
||||
self.assertEqual(len(updater.status()['pending']), 1)
|
||||
updater.recover()
|
||||
self.assertEqual(updater.status()['highest_sequence'], 1)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
Reference in New Issue
Block a user