Initial server source import

This commit is contained in:
sashatrask
2026-09-30 20:30:56 +03:00
commit 170dd941b9
498 changed files with 261563 additions and 0 deletions
+849
View File
@@ -0,0 +1,849 @@
import copy
from contextlib import contextmanager, ExitStack
import hashlib
import os
from pathlib import Path
import stat
import sys
import tempfile
import unittest
import uuid
from unittest import mock
import yaml
ROOT = Path(__file__).resolve().parents[1]
APP_DIR = ROOT / 'app'
sys.path.insert(0, str(APP_DIR))
import host_agent_apply
from host_agent_protocol import HostAgentAction, HostAgentRequest
import runtime_document
_REAL_PRIVATE_FILE_LOCK = host_agent_apply.PrivateFileLock
class _Connection:
is_postgres = True
class _Database:
def __init__(self):
self.conn = _Connection()
self.claims = []
self.failure = None
self.replayed = False
def claim_runtime_operation_execution(self, **values):
self.claims.append(values)
if self.failure is not None:
raise self.failure
return {'status': 'running', 'replayed': self.replayed}
class _TestLock:
held = set()
def __init__(self, path):
self.path = os.fspath(path)
self.acquired = False
def acquire(self):
if self.path in self.held:
raise BlockingIOError('held')
self.held.add(self.path)
self.acquired = True
return self
def release(self):
if self.acquired:
self.held.remove(self.path)
self.acquired = False
class HostAgentApplyTests(unittest.TestCase):
@staticmethod
def proof(request):
return host_agent_apply._new_stopped_runtime_proof(request.operation_id)
@staticmethod
def rollback_proof(request):
return host_agent_apply._new_stopped_runtime_proof(
request.operation_id, purpose='rollback',
)
def documents(self):
template_payload = (APP_DIR / 'config.linux.yaml').read_bytes()
template = runtime_document.load_yaml_document(
template_payload,
max_bytes=runtime_document.MAX_CONFIG_DOCUMENT_BYTES,
)
config = copy.deepcopy(template)
docker_pool = config['sources']['dockerhub']['auth_pool']
pools = {
source['auth_pool']
for source in config['sources'].values()
if source.get('auth_pool')
}
secrets = {'auth_pools': {
pool: [{
'name': pool + '_1',
**({'username': 'fixture-user'} if pool == docker_pool else {}),
'token': 'fixture-token',
}]
for pool in pools
}}
return template_payload, config, secrets
@staticmethod
def write_private(path, payload):
path.write_bytes(payload)
os.chmod(path, 0o600)
@contextmanager
def store(
self, *, candidate_config=True, candidate_secrets=True, real_lock=False,
):
template_payload, config, secrets = self.documents()
active_config = yaml.safe_dump(config).encode('utf-8')
active_secrets = yaml.safe_dump(secrets).encode('utf-8')
proposed_config = active_config + b'# candidate config\n'
proposed_secrets = active_secrets + b'# candidate secrets\n'
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
active = root / 'active'
candidates = root / 'candidates'
apply = root / 'apply'
backups = root / 'backups'
for directory in (active, candidates, apply, backups):
directory.mkdir(mode=0o700)
os.chmod(directory, 0o700)
active_config_path = active / 'config.yaml'
active_secrets_path = active / 'secrets.yaml'
candidate_config_path = candidates / 'config.yaml'
candidate_secrets_path = candidates / 'secrets.yaml'
template_path = root / 'template.yaml'
self.write_private(active_config_path, active_config)
self.write_private(active_secrets_path, active_secrets)
self.write_private(template_path, template_payload)
if candidate_config:
self.write_private(candidate_config_path, proposed_config)
if candidate_secrets:
self.write_private(candidate_secrets_path, proposed_secrets)
details = os.stat(root)
uid = getattr(details, 'st_uid', 0)
gid = getattr(details, 'st_gid', 0)
runtime_uid = (
10001
if os.name != 'nt' and os.geteuid() == 0
else uid
)
runtime_gid = (
10001
if os.name != 'nt' and os.geteuid() == 0
else gid
)
if os.name != 'nt' and runtime_uid != uid:
os.chown(candidates, runtime_uid, runtime_gid)
for path in (
active_config_path,
active_secrets_path,
candidate_config_path,
candidate_secrets_path,
):
if path.exists():
os.chown(path, runtime_uid, runtime_gid)
values = {
'root': root,
'active': active,
'candidates': candidates,
'backups': backups,
'active_config_path': active_config_path,
'active_secrets_path': active_secrets_path,
'candidate_config_path': candidate_config_path,
'candidate_secrets_path': candidate_secrets_path,
'active_config': active_config,
'active_secrets': active_secrets,
'proposed_config': proposed_config,
'proposed_secrets': proposed_secrets,
}
with ExitStack() as stack:
stack.enter_context(mock.patch.multiple(
host_agent_apply,
HOST_ROOT_UID=uid,
HOST_ROOT_GID=gid,
HOST_RUNTIME_UID=runtime_uid,
HOST_RUNTIME_GID=runtime_gid,
HOST_ACTIVE_DIRECTORY=active,
HOST_ACTIVE_DIRECTORY_MODE=0o700,
HOST_ACTIVE_CONFIG_PATH=active_config_path,
HOST_ACTIVE_SECRETS_PATH=active_secrets_path,
HOST_CANDIDATE_DIRECTORY=candidates,
HOST_CANDIDATE_DIRECTORY_MODE=0o700,
HOST_CONFIG_CANDIDATE_PATH=candidate_config_path,
HOST_SECRETS_CANDIDATE_PATH=candidate_secrets_path,
HOST_APPLY_DIRECTORY=apply,
HOST_APPLY_LOCK_PATH=apply / 'apply.lock',
HOST_BACKUP_ROOT=backups,
HOST_BACKUP_DIRECTORY_MODE=0o700,
HOST_TEMPLATE_PATH=template_path,
PrivateFileLock=(
_REAL_PRIVATE_FILE_LOCK if real_lock else _TestLock
),
))
yield values
@staticmethod
def request(store, action='apply-both'):
enum = HostAgentAction(action)
return HostAgentRequest(
operation_id=str(uuid.uuid4()),
action=enum,
active_config_sha256=hashlib.sha256(
store['active_config'],
).hexdigest(),
active_secrets_sha256=hashlib.sha256(
store['active_secrets'],
).hexdigest(),
candidate_config_sha256=(
hashlib.sha256(store['proposed_config']).hexdigest()
if enum in (HostAgentAction.APPLY_CONFIG, HostAgentAction.APPLY_BOTH)
else None
),
candidate_secrets_sha256=(
hashlib.sha256(store['proposed_secrets']).hexdigest()
if enum in (HostAgentAction.APPLY_SECRETS, HostAgentAction.APPLY_BOTH)
else None
),
)
def setUp(self):
_TestLock.held.clear()
def test_claims_validates_backs_up_and_atomically_replaces_fixed_documents(self):
with self.store() as store:
database = _Database()
request = self.request(store)
with host_agent_apply.HostApplySession(request, database) as session:
session.backup()
result = session.replace(self.proof(request))
operation_backup = store['backups'] / request.operation_id
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(),
store['active_config'],
)
self.assertEqual(
(operation_backup / 'secrets.yaml').read_bytes(),
store['active_secrets'],
)
self.assertEqual(
store['active_config_path'].read_bytes(), store['proposed_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(), store['proposed_secrets'],
)
self.assertEqual(
store['candidate_config_path'].read_bytes(), store['proposed_config'],
)
self.assertEqual(
store['candidate_secrets_path'].read_bytes(), store['proposed_secrets'],
)
self.assertEqual(result, {
'active_config_sha256': request.candidate_config_sha256,
'active_secrets_sha256': request.candidate_secrets_sha256,
})
self.assertEqual(database.claims, [{
'operation_id': request.operation_id,
'action': 'apply-both',
'expected_identity': {
'active_config_sha256': request.active_config_sha256,
'active_secrets_sha256': request.active_secrets_sha256,
'candidate_config_sha256': request.candidate_config_sha256,
'candidate_secrets_sha256': request.candidate_secrets_sha256,
},
}])
def test_replace_requires_authentic_operation_bound_stopped_proof(self):
with self.store() as store:
request = self.request(store)
invalid = (
None,
object(),
host_agent_apply._new_stopped_runtime_proof(str(uuid.uuid4())),
host_agent_apply._new_stopped_runtime_proof(
request.operation_id, purpose='rollback',
),
host_agent_apply._StoppedRuntimeProof(
request.operation_id, 'forward', object(), object(),
),
)
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
for proof in invalid:
with self.subTest(proof=type(proof).__name__):
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.replace(proof)
self.assertEqual(raised.exception.category, 'state')
self.assertEqual(
store['active_config_path'].read_bytes(),
store['active_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(),
store['active_secrets'],
)
def test_restart_revalidates_without_creating_backup_or_replacing_files(self):
with self.store(candidate_config=False, candidate_secrets=False) as store:
database = _Database()
request = self.request(store, 'restart')
with host_agent_apply.HostApplySession(request, database) as session:
session.backup()
result = session.replace(self.proof(request))
self.assertFalse((store['backups'] / request.operation_id).exists())
self.assertEqual(store['active_config_path'].read_bytes(), store['active_config'])
self.assertEqual(store['active_secrets_path'].read_bytes(), store['active_secrets'])
self.assertEqual(result, {
'active_config_sha256': request.active_config_sha256,
'active_secrets_sha256': request.active_secrets_sha256,
})
def test_singleton_lock_rejects_second_session_before_database_claim(self):
with self.store() as store:
request = self.request(store)
first_database = _Database()
second_database = _Database()
first = host_agent_apply.HostApplySession(request, first_database)
first.__enter__()
try:
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
host_agent_apply.HostApplySession(
request, second_database,
).__enter__()
self.assertEqual(raised.exception.category, 'busy')
self.assertEqual(second_database.claims, [])
finally:
first.close()
def test_persisted_authority_failure_happens_before_document_reads(self):
with self.store() as store:
database = _Database()
database.failure = RuntimeError('database detail')
request = self.request(store)
with mock.patch.object(
host_agent_apply, '_snapshot_file',
) as snapshot, self.assertRaises(host_agent_apply.HostApplyError) as raised:
with host_agent_apply.HostApplySession(request, database):
pass
self.assertEqual(raised.exception.category, 'authority')
snapshot.assert_not_called()
self.assertNotIn('database detail', str(raised.exception))
def test_non_postgres_authority_fails_before_document_reads(self):
with self.store() as store:
database = _Database()
database.conn.is_postgres = False
with mock.patch.object(
host_agent_apply, '_snapshot_file',
) as snapshot, self.assertRaises(host_agent_apply.HostApplyError) as raised:
with host_agent_apply.HostApplySession(
self.request(store), database,
):
pass
self.assertEqual(raised.exception.category, 'authority')
self.assertEqual(database.claims, [])
snapshot.assert_not_called()
def test_same_operation_failed_hold_replay_skips_document_preparation(self):
with self.store() as store:
request = self.request(store)
database = _Database()
with mock.patch.object(
host_agent_apply, 'failed_hold_operation',
return_value=request.operation_id,
), mock.patch.object(
host_agent_apply.HostApplySession, '_prepare',
) as prepare:
with host_agent_apply.HostApplySession(
request, database,
) as session:
self.assertTrue(session._failed_hold_replay)
self.assertIsNone(session._active)
self.assertEqual(session.publication_state, 'original')
prepare.assert_not_called()
self.assertEqual(len(database.claims), 1)
def test_invalid_candidate_is_rejected_without_rendering_secret_bytes(self):
sentinel = b'never-render-this-secret-token'
with self.store() as store:
store['proposed_secrets'] = b'auth_pools: [' + sentinel
self.write_private(
store['candidate_secrets_path'], store['proposed_secrets'],
)
request = self.request(store, 'apply-secrets')
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
with host_agent_apply.HostApplySession(request, _Database()):
pass
self.assertEqual(raised.exception.category, 'validation')
self.assertNotIn(sentinel.decode('ascii'), str(raised.exception))
self.assertNotIn(sentinel.decode('ascii'), repr(raised.exception))
def test_hash_drift_before_publication_preserves_active_files(self):
with self.store() as store:
request = self.request(store)
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
store['candidate_config_path'].write_bytes(b'changed concurrently')
os.chmod(store['candidate_config_path'], 0o600)
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.replace(self.proof(request))
self.assertEqual(raised.exception.category, 'identity')
self.assertEqual(store['active_config_path'].read_bytes(), store['active_config'])
self.assertEqual(store['active_secrets_path'].read_bytes(), store['active_secrets'])
self.assertEqual(list(store['active'].glob('*.stage')), [])
def test_completed_publication_replay_verifies_backup_without_replacing_again(self):
with self.store() as store:
request = self.request(store)
database = _Database()
with host_agent_apply.HostApplySession(request, database) as session:
session.backup()
expected = session.replace(self.proof(request))
database.replayed = True
with mock.patch.object(
host_agent_apply, 'durable_replace',
) as replace, host_agent_apply.HostApplySession(
request, database,
) as replay:
replay.backup()
actual = replay.replace(self.proof(request))
self.assertEqual(actual, expected)
replace.assert_not_called()
self.assertEqual(
store['active_config_path'].read_bytes(), store['proposed_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(), store['proposed_secrets'],
)
def test_interrupted_second_backup_replays_without_changing_active_files(self):
with self.store() as store:
request = self.request(store)
database = _Database()
create = host_agent_apply._create_owned_file
def interrupt_second(destination, *args, **kwargs):
if Path(destination).name == 'secrets.yaml':
raise SystemExit()
return create(destination, *args, **kwargs)
with host_agent_apply.HostApplySession(request, database) as session:
with mock.patch.object(
host_agent_apply, '_create_owned_file',
side_effect=interrupt_second,
), self.assertRaises(SystemExit):
session.backup()
operation_backup = store['backups'] / request.operation_id
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(),
store['active_config'],
)
self.assertFalse((operation_backup / 'secrets.yaml').exists())
database.replayed = True
with host_agent_apply.HostApplySession(request, database) as replay:
replay.backup()
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(),
store['active_config'],
)
self.assertEqual(
(operation_backup / 'secrets.yaml').read_bytes(),
store['active_secrets'],
)
self.assertEqual(
store['active_config_path'].read_bytes(), store['active_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(), store['active_secrets'],
)
def test_partial_publication_replay_is_recovery_only_and_restores_both(self):
with self.store() as store:
request = self.request(store)
database = _Database()
with host_agent_apply.HostApplySession(request, database) as session:
session.backup()
database.replayed = True
self.write_private(
store['active_config_path'], store['proposed_config'],
)
with host_agent_apply.HostApplySession(request, database) as replay:
self.assertEqual(replay.publication_state, 'partial')
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
replay.replace(self.proof(request))
self.assertEqual(raised.exception.category, 'partial')
result = replay.restore_backups(self.rollback_proof(request))
self.assertEqual(result, {
'active_config_sha256': request.active_config_sha256,
'active_secrets_sha256': request.active_secrets_sha256,
})
self.assertEqual(
store['active_config_path'].read_bytes(), store['active_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(), store['active_secrets'],
)
def test_completed_publication_can_be_restored_byte_identically_once(self):
with self.store() as store:
request = self.request(store)
with host_agent_apply.HostApplySession(
request, _Database(),
) as session:
session.backup()
session.replace(self.proof(request))
proof = self.rollback_proof(request)
result = session.restore_backups(proof)
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.restore_backups(proof)
self.assertEqual(raised.exception.category, 'state')
self.assertEqual(result, {
'active_config_sha256': request.active_config_sha256,
'active_secrets_sha256': request.active_secrets_sha256,
})
self.assertEqual(
store['active_config_path'].read_bytes(), store['active_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(), store['active_secrets'],
)
operation_backup = store['backups'] / request.operation_id
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(),
store['active_config'],
)
self.assertEqual(
(operation_backup / 'secrets.yaml').read_bytes(),
store['active_secrets'],
)
def test_restore_failure_after_first_publish_records_partial_state(self):
with self.store() as store:
request = self.request(store)
with host_agent_apply.HostApplySession(
request, _Database(),
) as session:
session.backup()
session.replace(self.proof(request))
replace = host_agent_apply.durable_replace
rollback_replaces = 0
def fail_second_rollback(source, destination):
nonlocal rollback_replaces
if source.name.endswith('.rollback'):
rollback_replaces += 1
if rollback_replaces == 2:
raise OSError('rollback detail')
return replace(source, destination)
with mock.patch.object(
host_agent_apply, 'durable_replace',
side_effect=fail_second_rollback,
):
with self.assertRaises(host_agent_apply.HostApplyError):
session.restore_backups(self.rollback_proof(request))
self.assertEqual(session.publication_state, 'partial')
self.assertEqual(
store['active_config_path'].read_bytes(), store['active_config'],
)
self.assertEqual(
store['active_secrets_path'].read_bytes(),
store['proposed_secrets'],
)
operation_backup = store['backups'] / request.operation_id
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(),
store['active_config'],
)
self.assertEqual(
(operation_backup / 'secrets.yaml').read_bytes(),
store['active_secrets'],
)
self.assertEqual(
store['candidate_config_path'].read_bytes(),
store['proposed_config'],
)
self.assertEqual(
store['candidate_secrets_path'].read_bytes(),
store['proposed_secrets'],
)
def test_restore_refuses_unknown_active_bytes_without_overwrite(self):
with self.store() as store:
request = self.request(store, 'apply-config')
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
session.replace(self.proof(request))
foreign = b'foreign active bytes'
self.write_private(store['active_config_path'], foreign)
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.restore_backups(self.rollback_proof(request))
self.assertEqual(raised.exception.category, 'identity')
self.assertEqual(store['active_config_path'].read_bytes(), foreign)
@unittest.skipUnless(
os.name != 'nt' and getattr(os, 'geteuid', lambda: -1)() == 0,
'requires distinct root and runtime ownership',
)
def test_replay_adopts_root_owned_original_left_during_rollback(self):
with self.store() as store:
request = self.request(store, 'apply-config')
database = _Database()
with host_agent_apply.HostApplySession(request, database) as session:
session.backup()
session.replace(self.proof(request))
stage = store['active'] / (
f'.config.yaml.{request.operation_id}.rollback'
)
host_agent_apply._create_owned_file(
stage,
store['active_config'],
uid=host_agent_apply.HOST_ROOT_UID,
gid=host_agent_apply.HOST_ROOT_GID,
mode=0o600,
expected_sha256=request.active_config_sha256,
)
host_agent_apply.durable_replace(
stage, store['active_config_path'],
)
self.assertEqual(
os.stat(store['active_config_path']).st_uid,
host_agent_apply.HOST_ROOT_UID,
)
database.replayed = True
with host_agent_apply.HostApplySession(request, database) as replay:
result = replay.restore_backups(self.rollback_proof(request))
self.assertEqual(result, {
'active_config_sha256': request.active_config_sha256,
'active_secrets_sha256': request.active_secrets_sha256,
})
self.assertEqual(
os.stat(store['active_config_path']).st_uid,
host_agent_apply.HOST_RUNTIME_UID,
)
self.assertEqual(
store['active_config_path'].read_bytes(), store['active_config'],
)
@unittest.skipUnless(
os.name != 'nt' and getattr(os, 'geteuid', lambda: -1)() == 0,
'requires distinct root and runtime ownership',
)
def test_replay_adopts_root_owned_candidate_left_after_publication(self):
with self.store() as store:
request = self.request(store, 'apply-config')
database = _Database()
with host_agent_apply.HostApplySession(request, database) as session:
session.backup()
stage = store['active'] / (
f'.config.yaml.{request.operation_id}.stage'
)
host_agent_apply._create_owned_file(
stage,
store['proposed_config'],
uid=host_agent_apply.HOST_ROOT_UID,
gid=host_agent_apply.HOST_ROOT_GID,
mode=0o600,
expected_sha256=request.candidate_config_sha256,
)
host_agent_apply.durable_replace(
stage, store['active_config_path'],
)
self.assertEqual(
os.stat(store['active_config_path']).st_uid,
host_agent_apply.HOST_ROOT_UID,
)
database.replayed = True
with host_agent_apply.HostApplySession(request, database) as replay:
replay.backup()
result = replay.replace(self.proof(request))
self.assertEqual(
os.stat(store['active_config_path']).st_uid,
host_agent_apply.HOST_RUNTIME_UID,
)
self.assertEqual(
result['active_config_sha256'], request.candidate_config_sha256,
)
def test_stale_fixed_stage_is_removed_before_replacement(self):
with self.store() as store:
request = self.request(store, 'apply-config')
stage = store['active'] / (
f'.config.yaml.{request.operation_id}.stage'
)
self.write_private(stage, b'incomplete prior stage')
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
session.replace(self.proof(request))
self.assertFalse(stage.exists())
self.assertEqual(
store['active_config_path'].read_bytes(), store['proposed_config'],
)
def test_stage_remains_root_owned_until_publication(self):
with self.store() as store:
request = self.request(store, 'apply-config')
real_create = host_agent_apply._create_owned_file
calls = []
def record(path, payload, **metadata):
calls.append((Path(path), dict(metadata)))
return real_create(path, payload, **metadata)
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
with mock.patch.object(
host_agent_apply, '_create_owned_file', side_effect=record,
):
session.replace(self.proof(request))
stage_calls = [item for item in calls if item[0].suffix == '.stage']
self.assertEqual(len(stage_calls), 1)
self.assertEqual(stage_calls[0][1]['uid'], host_agent_apply.HOST_ROOT_UID)
self.assertEqual(stage_calls[0][1]['gid'], host_agent_apply.HOST_ROOT_GID)
@unittest.skipIf(os.name == 'nt', 'POSIX displaced-inode guard')
def test_active_write_after_revalidation_is_detected_as_partial(self):
with self.store() as store:
request = self.request(store, 'apply-config')
real_replace = host_agent_apply.durable_replace
def mutate_then_replace(source, destination):
self.write_private(Path(destination), b'late concurrent write')
real_replace(source, destination)
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
with mock.patch.object(
host_agent_apply,
'durable_replace',
side_effect=mutate_then_replace,
), self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.replace(self.proof(request))
self.assertEqual(raised.exception.category, 'partial')
self.assertEqual(
store['active_config_path'].read_bytes(), store['proposed_config'],
)
self.assertEqual(
(
store['backups'] / request.operation_id / 'config.yaml'
).read_bytes(),
store['active_config'],
)
def test_existing_backup_must_be_byte_identical(self):
with self.store() as store:
request = self.request(store, 'apply-config')
operation_backup = store['backups'] / request.operation_id
operation_backup.mkdir(mode=0o700)
os.chmod(operation_backup, 0o700)
self.write_private(operation_backup / 'config.yaml', b'foreign backup')
with host_agent_apply.HostApplySession(request, _Database()) as session:
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.backup()
self.assertEqual(raised.exception.category, 'identity')
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(), b'foreign backup',
)
def test_combined_second_publication_failure_is_reported_as_partial(self):
with self.store() as store:
request = self.request(store)
real_replace = host_agent_apply.durable_replace
calls = []
def fail_second(source, destination):
calls.append(Path(destination).name)
if len(calls) == 2:
raise OSError('second publication failed')
real_replace(source, destination)
with host_agent_apply.HostApplySession(request, _Database()) as session:
session.backup()
with mock.patch.object(
host_agent_apply, 'durable_replace', side_effect=fail_second,
), self.assertRaises(host_agent_apply.HostApplyError) as raised:
session.replace(self.proof(request))
self.assertEqual(raised.exception.category, 'partial')
self.assertEqual(store['active_config_path'].read_bytes(), store['proposed_config'])
self.assertEqual(store['active_secrets_path'].read_bytes(), store['active_secrets'])
operation_backup = store['backups'] / request.operation_id
self.assertEqual(
(operation_backup / 'config.yaml').read_bytes(), store['active_config'],
)
self.assertEqual(
(operation_backup / 'secrets.yaml').read_bytes(), store['active_secrets'],
)
@unittest.skipIf(os.name == 'nt', 'POSIX link metadata test')
def test_candidate_hardlink_is_rejected_without_claimed_content_publication(self):
with self.store(candidate_config=False) as store:
os.link(store['active_config_path'], store['candidate_config_path'])
request = self.request(store, 'apply-config')
request = HostAgentRequest(
operation_id=request.operation_id,
action=request.action,
active_config_sha256=request.active_config_sha256,
active_secrets_sha256=request.active_secrets_sha256,
candidate_config_sha256=request.active_config_sha256,
candidate_secrets_sha256=None,
)
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
with host_agent_apply.HostApplySession(request, _Database()):
pass
self.assertEqual(raised.exception.category, 'filesystem')
@unittest.skipIf(os.name == 'nt', 'POSIX flock integration test')
def test_real_filesystem_lock_excludes_concurrent_host_session(self):
with self.store(real_lock=True) as store:
request = self.request(store, 'restart')
first = host_agent_apply.HostApplySession(request, _Database())
first.__enter__()
try:
with self.assertRaises(host_agent_apply.HostApplyError) as raised:
host_agent_apply.HostApplySession(
request, _Database(),
).__enter__()
self.assertEqual(raised.exception.category, 'busy')
finally:
first.close()
if __name__ == '__main__':
unittest.main()