P08-03: 配置の通常操作受入
This commit is contained in:
@@ -12,6 +12,7 @@ import sys
|
||||
import struct
|
||||
import zlib
|
||||
import subprocess
|
||||
import copy
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
@@ -53,6 +54,68 @@ def load(path): return json.loads(Path(path).read_bytes())
|
||||
def require(ok, message):
|
||||
if not ok: raise ValueError(message)
|
||||
|
||||
def scalar(value, kind=None):
|
||||
kind = kind or ('bool' if isinstance(value, bool) else 'int' if isinstance(value, int) else 'String')
|
||||
return {'type':kind, 'text':('&' if kind == 'StringName' else '')+json.dumps(value, ensure_ascii=False)}
|
||||
|
||||
def dictionary(entries, key_type='String'):
|
||||
return {'type':'Dictionary', 'entries':[[scalar(k, key_type), v] for k,v in entries.items()]}
|
||||
|
||||
def typed_member(node, *path):
|
||||
for key in path:
|
||||
matches = [v for k,v in node['entries'] if decode(k) == key]
|
||||
require(len(matches) == 1, 'missing/duplicate typed model member '+repr(path))
|
||||
node = matches[0]
|
||||
return node
|
||||
|
||||
def expected_operation(formal, result, event_type):
|
||||
"""Rebuild the full typed state from source rules, never from the actual delta.
|
||||
|
||||
Runtime 4680-4720, StableIdGenerator.next_id, SimulationDomainEvent.to_dictionary
|
||||
and DomainEventStream.publish/create_snapshot define this single notification.
|
||||
"""
|
||||
saved = decode(formal)
|
||||
minute = saved['clock']['absolute_minute']
|
||||
values = {'ok':bool(result.get('ok',False)), 'code':str(result.get('code','UNKNOWN')),
|
||||
'message':str(result.get('message','')), 'operation':str(result.get('operation','unknown')),
|
||||
'save_path':str(result.get('save_path',result.get('path',''))), 'reason':str(result.get('reason','')),
|
||||
'request_id':int(result.get('request_id',0)),
|
||||
'checkpoint_absolute_minute':int(result.get('checkpoint_absolute_minute',minute)),
|
||||
'duration_us':int(result.get('duration_us',0)), 'runtime_duration_us':int(result.get('runtime_duration_us',0))}
|
||||
names = {'code','operation','reason','selected_candidate_id','selected_candidate_kind'}
|
||||
if isinstance(result.get('selected'),dict):
|
||||
selected = result['selected']
|
||||
values.update(selected_candidate_id=str(selected.get('id','')),
|
||||
selected_candidate_kind=str(selected.get('kind','')), selected_generation=int(selected.get('generation',-1)))
|
||||
if isinstance(result.get('manifest'),dict):
|
||||
manifest = result['manifest']
|
||||
values.update(save_id=str(manifest.get('save_id','')), saved_absolute_minute=int(manifest.get('absolute_minute',-1)))
|
||||
expected = copy.deepcopy(formal)
|
||||
runtime = typed_member(expected,'systems','runtime')
|
||||
counters = typed_member(runtime,'id_generator','counters')
|
||||
count = decode(counters).get('domain_event',0)+1
|
||||
require(0 < count <= 999999999999, 'invalid domain event counter')
|
||||
for key,value in counters['entries']:
|
||||
if decode(key) == 'domain_event':
|
||||
value.update(scalar(count)); break
|
||||
else:
|
||||
counters['entries'].append([scalar('domain_event'),scalar(count)])
|
||||
event_id = 'domain_event.%012d' % count
|
||||
stream = typed_member(runtime,'domain_events')
|
||||
published = typed_member(stream,'published_ids')
|
||||
require(event_id not in decode(published), 'duplicate operation notification')
|
||||
published['items'].append(scalar(event_id))
|
||||
published['items'].sort(key=decode)
|
||||
payload = dictionary({k:scalar(v,'StringName' if k in names else None) for k,v in values.items()},'StringName')
|
||||
event = dictionary({'event_id':scalar(event_id), 'event_type':scalar(event_type),
|
||||
'absolute_minute':scalar(minute), 'source_service':scalar('save_service'),
|
||||
'entity_ids':{'type':'Array','element_type':4,'element_class':'','items':[]},
|
||||
'payload':payload, 'causation_command_id':scalar(0)})
|
||||
recent = typed_member(stream,'recent_events')
|
||||
recent['items'].append(event)
|
||||
recent['items'] = recent['items'][-256:]
|
||||
return expected
|
||||
|
||||
def png(data):
|
||||
require(data.startswith(b'\x89PNG\r\n\x1a\n'), 'missing OS screenshot')
|
||||
offset = 8; chunks = []; payload = b''
|
||||
@@ -312,6 +375,8 @@ def check(project_root, evidence_dir):
|
||||
artifact(directory+'/'+name+'.var')
|
||||
return document(directory+'/'+name+'.json')
|
||||
rows = []
|
||||
normal_results = {}
|
||||
inspections = {}
|
||||
for role, pid in zip(('writer', 'reader'), expected_pids):
|
||||
process = document(directory+'/'+role+'-process.json')
|
||||
require(process['pid'] == pid and process['failures'] == 0 and process['checks'] > 0, 'process attribution')
|
||||
@@ -329,6 +394,10 @@ def check(project_root, evidence_dir):
|
||||
require(isinstance(result, dict) and result.get('ok') is True, 'actual save/load failed')
|
||||
if record['operation'] == 'runtime_operation':
|
||||
operations.append(result.get('operation'))
|
||||
normal_results[role] = result
|
||||
if record['operation'] == 'formal_inspect':
|
||||
require(result.get('read_only') is True and 'snapshot' in result, 'missing read-only formal model')
|
||||
inspections[role] = typed_member(record['result'],'snapshot')
|
||||
require(('save' in operations) if role == 'writer' else any(op in ('load','recovery','recovery_candidate') for op in operations), 'missing actual normal save/load result')
|
||||
doc = document(directory+'/'+role+'-actions.json')
|
||||
require(doc['setup'] == 'diagnostic', 'unlabelled diagnostic')
|
||||
@@ -368,9 +437,33 @@ def check(project_root, evidence_dir):
|
||||
require(len(matches) == 1 and {tuple(c) for c in matches[0]['occupied_cells']} == cells, 'production footprint mismatch')
|
||||
rows += doc['actions']
|
||||
checkpoint = typed('writer-checkpoint')
|
||||
require(checkpoint == typed('reader-checkpoint'), 'wrong resume model')
|
||||
state(checkpoint)
|
||||
require(sha(artifact(directory+'/writer-checkpoint.var')) == sha(artifact(directory+'/reader-checkpoint.var')), 'wrong raw resume')
|
||||
formal = typed('writer-formal-model')
|
||||
require(not typed_differences(typed('writer-presave'),formal), 'wrong presave formal model')
|
||||
require(sha(artifact(directory+'/writer-presave.var')) == sha(artifact(directory+'/writer-formal-model.var')), 'wrong raw presave formal model')
|
||||
require(not typed_differences(formal,typed('reader-formal-model')), 'wrong formal resume model')
|
||||
require(sha(artifact(directory+'/writer-formal-model.var')) == sha(artifact(directory+'/reader-formal-model.var')), 'wrong raw formal resume model')
|
||||
for role in ('writer','reader'):
|
||||
require(role in inspections and not typed_differences(formal,inspections[role]), 'wrong read-only formal model')
|
||||
result = normal_results[role]
|
||||
require(result['operation'] == ('save' if role == 'writer' else 'recovery'), 'wrong operation notification kind')
|
||||
event_type = 'SAVE_CHECKPOINT_SUCCEEDED' if role == 'writer' else 'SAVE_RECOVERY_LOADED'
|
||||
expected = expected_operation(formal,result,event_type)
|
||||
differences = typed_differences(expected,typed(role+'-checkpoint'))
|
||||
require(not differences, 'wrong resume model: source-defined operation drift '+role+' '+repr(differences))
|
||||
require(not typed_differences(expected,typed(role+'-expected-operation')), 'wrong reconstructed operation model')
|
||||
require(sha(artifact(directory+'/'+role+'-expected-operation.var')) == sha(artifact(directory+'/'+role+'-checkpoint.var')), 'wrong raw operation model')
|
||||
require(not typed_differences(typed(role+'-checkpoint'),typed(role+'-operation-actual')), 'inspection changed live model')
|
||||
require(sha(artifact(directory+'/'+role+'-operation-actual.var')) == sha(artifact(directory+'/'+role+'-checkpoint.var')), 'wrong raw inspection preservation')
|
||||
comparison = document(directory+'/'+role+'-operation-comparison.json')
|
||||
require(comparison == {'pid':expected_pids[0 if role == 'writer' else 1],
|
||||
'delta_kind':'source_defined_notification','formal_model':role+'-formal-model',
|
||||
'actual_model':role+'-checkpoint','expected_model':role+'-expected-operation',
|
||||
'returned_result':comparison['returned_result'],
|
||||
'saved_recent_count':len(decode(formal)['systems']['runtime']['domain_events']['recent_events']),
|
||||
'expected_recent_count':len(decode(expected)['systems']['runtime']['domain_events']['recent_events']),
|
||||
'actual_recent_count':len(decode(typed(role+'-checkpoint'))['systems']['runtime']['domain_events']['recent_events']),
|
||||
'whole_raw_equal':True} and decode(comparison['returned_result']) == result, 'wrong operation comparison attribution')
|
||||
saved = document(directory+'/save.json')
|
||||
require(set(saved['members']) == set(MEMBERS) and saved['pid'] == expected_pids[0], 'missing formal members/writer')
|
||||
for member in MEMBERS:
|
||||
|
||||
@@ -135,6 +135,115 @@ class EvidenceTests(unittest.TestCase):
|
||||
self.edit('pair/reader-save-result-0001.json', lambda d: d.update(result=F.typed({'ok':True,'operation':'load'}), requested_path='/outside/formal'))
|
||||
self.reject('save outside private user profile')
|
||||
|
||||
def test_notification_payload_is_recomputed_from_returned_dict(self):
|
||||
path = 'pair/reader-checkpoint.json'
|
||||
original = (self.out/path).read_bytes()
|
||||
fields = ('ok','code','message','operation','save_path','reason','request_id',
|
||||
'checkpoint_absolute_minute','duration_us','runtime_duration_us',
|
||||
'selected_candidate_id','selected_candidate_kind','selected_generation',
|
||||
'save_id','saved_absolute_minute')
|
||||
for field in fields:
|
||||
with self.subTest(field=field):
|
||||
data = json.loads(original)
|
||||
event = C.typed_member(data,'systems','runtime','domain_events','recent_events')['items'][-1]
|
||||
value = C.typed_member(event,'payload',field)
|
||||
decoded = C.decode(value)
|
||||
changed = not decoded if isinstance(decoded,bool) else decoded+1 if isinstance(decoded,int) else decoded+'-drift'
|
||||
value.update(C.scalar(changed,value['type']))
|
||||
F.put(self.out/path,data); F.repin(self.out)
|
||||
self.reject('source-defined operation drift')
|
||||
(self.out/path).write_bytes(original); F.repin(self.out)
|
||||
|
||||
def test_notification_envelope_cannot_drift(self):
|
||||
path = 'pair/writer-checkpoint.json'
|
||||
original = (self.out/path).read_bytes()
|
||||
for field in ('event_id','event_type','source_service','absolute_minute','causation_command_id','entity_ids'):
|
||||
with self.subTest(field=field):
|
||||
data = json.loads(original)
|
||||
event = C.typed_member(data,'systems','runtime','domain_events','recent_events')['items'][-1]
|
||||
value = C.typed_member(event,field)
|
||||
if field == 'entity_ids': value['items'].append(C.scalar('unexpected-entity'))
|
||||
else:
|
||||
old = C.decode(value)
|
||||
value.update(C.scalar(old+1 if isinstance(old,int) else old+'-drift',value['type']))
|
||||
F.put(self.out/path,data); F.repin(self.out)
|
||||
self.reject('source-defined operation drift')
|
||||
(self.out/path).write_bytes(original); F.repin(self.out)
|
||||
|
||||
def test_notification_counter_must_increment_exactly_once(self):
|
||||
def corrupt(data):
|
||||
C.typed_member(data,'systems','runtime','id_generator','counters','domain_event')['text'] = '9'
|
||||
self.edit('pair/reader-checkpoint.json',corrupt)
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_notification_published_ids_cannot_be_omitted(self):
|
||||
self.edit('pair/writer-checkpoint.json',lambda d: C.typed_member(d,'systems','runtime','domain_events','published_ids')['items'].clear())
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_extra_notification_is_rejected(self):
|
||||
def corrupt(data):
|
||||
events = C.typed_member(data,'systems','runtime','domain_events','recent_events')['items']
|
||||
events.append(copy.deepcopy(events[-1]))
|
||||
self.edit('pair/reader-checkpoint.json',corrupt)
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_existing_event_history_cannot_be_rewritten(self):
|
||||
def corrupt(data):
|
||||
event = C.typed_member(data,'systems','runtime','domain_events','recent_events')['items'][0]
|
||||
C.typed_member(event,'event_type').update(text='"REWRITTEN_HISTORY"')
|
||||
self.edit('pair/reader-checkpoint.json',corrupt)
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_postsave_unrelated_resource_drift_is_rejected(self):
|
||||
self.edit('pair/writer-checkpoint.json',lambda d: C.typed_member(d,'game_state','resources','gold').update(text='9999'))
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_postload_unknown_field_drift_is_rejected(self):
|
||||
self.edit('pair/reader-checkpoint.json',lambda d: C.typed_member(d,'systems','runtime','inventory')['entries'].append([C.scalar('unknown'),C.scalar(1)]))
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_presave_must_match_read_only_formal_model(self):
|
||||
self.edit('pair/writer-presave.json',lambda d: C.typed_member(d,'clock','absolute_minute').update(text='361'))
|
||||
self.reject('wrong presave formal model')
|
||||
|
||||
def test_read_only_inspection_must_match_complete_formal_model(self):
|
||||
self.edit('pair/reader-save-result-0002.json',lambda d: C.typed_member(d['result'],'snapshot','clock','absolute_minute').update(text='361'))
|
||||
self.reject('wrong read-only formal model')
|
||||
|
||||
def test_inspection_cannot_lose_read_only_attribution(self):
|
||||
self.edit('pair/writer-save-result-0002.json',lambda d: C.typed_member(d['result'],'read_only').update(text='false'))
|
||||
self.reject('missing read-only formal model')
|
||||
|
||||
def test_returned_result_payload_tampering_is_rejected(self):
|
||||
self.edit('pair/reader-save-result-0001.json',lambda d: C.typed_member(d['result'],'duration_us').update(text='999'))
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_notification_payload_type_drift_is_rejected(self):
|
||||
def corrupt(data):
|
||||
event = C.typed_member(data,'systems','runtime','domain_events','recent_events')['items'][-1]
|
||||
C.typed_member(event,'payload','operation')['type'] = 'String'
|
||||
C.typed_member(event,'payload','operation')['text'] = '"recovery"'
|
||||
self.edit('pair/reader-checkpoint.json',corrupt)
|
||||
self.reject('source-defined operation drift')
|
||||
|
||||
def test_operation_count_is_source_derived_with_recent_limit(self):
|
||||
model = C.decode(json.loads((self.out/'pair/writer-formal-model.json').read_text()))
|
||||
runtime = model['systems']['runtime']
|
||||
runtime['id_generator']['counters']['domain_event'] = 900
|
||||
stream = runtime['domain_events']
|
||||
stream['published_ids'] = ['domain_event.%012d' % n for n in range(1,901)]
|
||||
stream['recent_events'] = [{'ordinal':n} for n in range(645,901)]
|
||||
expected = C.decode(C.expected_operation(F.typed(model),{'ok':True},'SAVE_CHECKPOINT_SUCCEEDED'))
|
||||
new = expected['systems']['runtime']
|
||||
self.assertEqual(901,new['id_generator']['counters']['domain_event'])
|
||||
self.assertEqual(901,len(new['domain_events']['published_ids']))
|
||||
self.assertEqual(256,len(new['domain_events']['recent_events']))
|
||||
self.assertEqual(stream['recent_events'][1:],new['domain_events']['recent_events'][:-1])
|
||||
self.assertEqual('domain_event.000000000901',new['domain_events']['recent_events'][-1]['event_id'])
|
||||
del runtime['id_generator']['counters']
|
||||
with self.assertRaisesRegex(ValueError,'missing/duplicate typed model member'):
|
||||
C.expected_operation(F.typed(model),{'ok':True},'SAVE_CHECKPOINT_SUCCEEDED')
|
||||
|
||||
def test_missing_formal_member(self):
|
||||
for name in ('formal','formal.bak'):
|
||||
with self.subTest(package=name):
|
||||
|
||||
Reference in New Issue
Block a user