81 lines
3.5 KiB
Python
81 lines
3.5 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
|
|
from provisioning.state import (Journal, ProvisionError, atomic_write,
|
|
check_heartbeat, exclusive_lock)
|
|
|
|
|
|
class StateTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name).resolve()
|
|
|
|
def test_atomic_private_file_and_symlink_rejection(self):
|
|
path = self.root / 'state' / 'record'
|
|
atomic_write(path, 'first')
|
|
atomic_write(path, 'second')
|
|
self.assertEqual(path.read_text(), 'second')
|
|
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
|
link = self.root / 'redirect'
|
|
link.symlink_to(path.parent)
|
|
with self.assertRaises(ProvisionError):
|
|
atomic_write(link / 'record', 'corruption')
|
|
self.assertEqual(path.read_text(), 'second')
|
|
|
|
def test_lock_rejects_competing_initializer_and_releases(self):
|
|
path = self.root / 'lock'
|
|
with exclusive_lock(path):
|
|
with self.assertRaises(ProvisionError):
|
|
with exclusive_lock(path):
|
|
self.fail('competing lock acquired')
|
|
with exclusive_lock(path):
|
|
pass
|
|
|
|
def test_failed_stage_is_retryable_and_success_is_reverified(self):
|
|
path = self.root / 'journal'
|
|
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
|
|
with self.assertRaises(RuntimeError):
|
|
record.stage('package', lambda: (_ for _ in ()).throw(RuntimeError('SECRET')))
|
|
self.assertNotIn('SECRET', path.read_text())
|
|
self.assertEqual(json.loads(path.read_text())['failed_stage'], 'package')
|
|
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
|
|
results = iter(({'version': 'one'}, {'version': 'two'}))
|
|
record.stage('package', lambda: next(results))
|
|
record.stage('package', lambda: next(results))
|
|
self.assertEqual(record.data['stages']['package']['result']['version'], 'two')
|
|
|
|
def test_identity_or_bundle_change_cannot_retarget_partial_run(self):
|
|
path = self.root / 'journal'
|
|
Journal(path, {'hostname': 'node1'}, 'rev', 'digest').save()
|
|
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'node1'}, 'other')):
|
|
with self.assertRaises(ProvisionError):
|
|
Journal(path, identity, 'rev', digest)
|
|
|
|
def test_unsupported_interface_cannot_resume_and_revisions_survive_retry(self):
|
|
path = self.root / 'journal'
|
|
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest').save()
|
|
resumed = Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
|
|
self.assertEqual(resumed.data['baseline_revision'], 'source-rev')
|
|
for version in (None, 999):
|
|
resumed.data['enrollment_interface'] = version
|
|
resumed.save()
|
|
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
|
|
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
|
|
|
|
def test_expired_heartbeat_prevents_next_operation(self):
|
|
beat = self.root / 'heartbeat'
|
|
beat.touch()
|
|
os.utime(beat, (100, 100))
|
|
check_heartbeat(beat, now=110)
|
|
with self.assertRaises(ProvisionError):
|
|
check_heartbeat(beat, now=200)
|
|
record = Journal(self.root / 'journal', {}, 'rev', 'digest')
|
|
called = []
|
|
with self.assertRaises(ProvisionError):
|
|
record.stage('next', lambda: called.append(True), beat)
|
|
self.assertEqual(called, [])
|