850 lines
36 KiB
Python
850 lines
36 KiB
Python
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()
|