97 lines
5.3 KiB
Python
97 lines
5.3 KiB
Python
"""Guard semantics and recovery tests; no Kubernetes access."""
|
|
from pathlib import Path
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
spec = importlib.util.spec_from_file_location('scanner_rollout', HERE / 'rollout.py')
|
|
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
|
C = json.loads((HERE / 'inputs.json').read_bytes())
|
|
|
|
|
|
def deployment():
|
|
init = [{'name': 'unrelated'}] * (C['guard_index'] + 1)
|
|
init[C['guard_index']] = {'name': 'verify-secret-scanner', 'command': C['guard_command']}
|
|
volumes = [{'name': 'unrelated'}] * (C['volume_index'] + 1)
|
|
volumes[C['volume_index']] = C['volume']
|
|
return copy.deepcopy({'metadata': {'uid': C['deployment_uid'], 'resourceVersion': '123'},
|
|
'spec': {'strategy': C['strategy'], 'template': {'spec': {
|
|
'initContainers': init, 'volumes': volumes,
|
|
'containers': [{'name': 'gitea', 'image': 'existing-image', 'env': [{'name': 'public', 'value': 'preserved'}]}]}}}})
|
|
|
|
|
|
def apply_patch(d, operations):
|
|
value = copy.deepcopy(d)
|
|
for op in operations:
|
|
parts = op['path'].strip('/').split('/'); obj = value
|
|
for key in parts[:-1]: obj = obj[int(key)] if isinstance(obj, list) else obj[key]
|
|
key = int(parts[-1]) if isinstance(obj, list) else parts[-1]
|
|
if op['op'] == 'test':
|
|
if obj[key] != op['value']: raise ValueError('test-failed')
|
|
else: obj[key] = op['value']
|
|
return value
|
|
|
|
|
|
class Tests(unittest.TestCase):
|
|
def test_forward_and_reverse_change_only_two_selected_fields(self):
|
|
before = deployment(); ops = m.patch_for(before, C)
|
|
self.assertEqual(sum(o['op'] == 'replace' for o in ops), 2)
|
|
after = apply_patch(before, ops)
|
|
self.assertEqual(after['spec']['template']['spec']['containers'], before['spec']['template']['spec']['containers'])
|
|
self.assertEqual(apply_patch(after, m.patch_for(after, C, True)), before)
|
|
|
|
def test_identity_guard_volume_strategy_and_resource_version_drift_refused(self):
|
|
for key in ('uid', 'guard', 'volume', 'strategy'):
|
|
d = deployment(); s = d['spec']['template']['spec']
|
|
if key == 'uid': d['metadata']['uid'] = 'other'
|
|
elif key == 'guard': s['initContainers'][C['guard_index']]['command'] = ['other']
|
|
elif key == 'volume': s['volumes'][C['volume_index']]['configMap']['name'] = 'other'
|
|
else: d['spec']['strategy'] = {'type': 'RollingUpdate'}
|
|
with self.subTest(key=key), self.assertRaises(RuntimeError): m.patch_for(d, C)
|
|
d = deployment(); ops = m.patch_for(d, C); d['metadata']['resourceVersion'] = '124'
|
|
with self.assertRaises(ValueError): apply_patch(d, ops)
|
|
|
|
def test_inventory_requires_all_scanner_hooks_and_preserves_protected_set(self):
|
|
repo = '/data/git/gitea-repositories/coulomb/example.git'
|
|
raw = ('775 1000 1000 ' + C['old_hook_sha256'] + ' ' + repo + '/hooks/' + m.HOOK + '\n' +
|
|
'755 1000 1000 ' + 'a'*64 + ' ' + repo + '/hooks/pre-receive\n').encode()
|
|
before = m.parse_inventory(raw, C, {C['old_hook_sha256']})
|
|
m.protected_equal(before, copy.deepcopy(before))
|
|
changed = copy.deepcopy(before); changed[repo]['pre-receive']['sha256'] = 'b'*64
|
|
with self.assertRaises(RuntimeError): m.protected_equal(before, changed)
|
|
with self.assertRaises(RuntimeError): m.parse_inventory(raw.splitlines(keepends=True)[1], C, {C['old_hook_sha256']})
|
|
with self.assertRaises(RuntimeError): m.parse_inventory(raw + raw, C, {C['old_hook_sha256']})
|
|
|
|
def test_lost_patch_response_attempts_guarded_recovery_and_retains_failure(self):
|
|
class Simulated(m.Rollout):
|
|
def __init__(self): super().__init__(C); self.recovered = False
|
|
def preflight(self): return deployment(), 'pod', {}
|
|
def event(self, name, **kw): self.receipt['events'].append({'event': name, **kw})
|
|
def shell(self, *a, **kw): return b'{}'
|
|
def probe(self, *a): pass
|
|
def migrate(self, *a): return {}
|
|
def deployment(self): return deployment()
|
|
def k(self, *a, **kw):
|
|
if 'patch' in a: raise RuntimeError('lost-response-after-patch')
|
|
return b''
|
|
def rollback(self, before=None): self.recovered = True
|
|
x = Simulated()
|
|
with self.assertRaisesRegex(RuntimeError, 'lost-response'): x.apply(Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'))
|
|
self.assertTrue(x.recovered)
|
|
self.assertIn('activation-failed', [e['event'] for e in x.receipt['events']])
|
|
self.assertNotIn('activation-verified', [e['event'] for e in x.receipt['events']])
|
|
|
|
def test_rollback_refuses_unknown_deployment_instead_of_overwriting(self):
|
|
class Simulated(m.Rollout):
|
|
def target(self): pass
|
|
def deployment(self):
|
|
d = deployment(); d['spec']['template']['spec']['initContainers'][C['guard_index']]['command'] = ['unknown']
|
|
return d
|
|
def k(self, *a, **kw): raise AssertionError('must not mutate')
|
|
with self.assertRaisesRegex(RuntimeError, 'unknown-guard'): Simulated(C).rollback()
|
|
|
|
|
|
if __name__ == '__main__': unittest.main()
|