fix(history): model sparse publication without relaxing live freshness

This commit is contained in:
ENELIX Agent
2026-10-03 08:04:28 +00:00
parent 6d9aba74b0
commit 5890093b80
15 changed files with 1787 additions and 22 deletions
@@ -0,0 +1,94 @@
"""Installer ordering/recovery tests; subprocesses, privileges and DB backup are simulated."""
import hashlib
import importlib.util
import json
import subprocess
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPT=Path(__file__).resolve().parents[1]/'commissioning/deploy_history_timing.py'
spec=importlib.util.spec_from_file_location('history_deploy_tests',SCRIPT)
d=importlib.util.module_from_spec(spec);spec.loader.exec_module(d)
class TimingDeploymentTest(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name)
self.package=self.root/'commissioning/history-timing-source';self.package.mkdir(parents=True)
files={}
for index,name in enumerate(d.TARGETS):
original=(b'old:'+name.encode()) if index%2==0 else None
target=self.root/name;target.parent.mkdir(parents=True,exist_ok=True)
if original is not None:target.write_bytes(original)
candidate=self.package/'source'/name;candidate.parent.mkdir(parents=True,exist_ok=True);candidate.write_bytes(b'new:'+name.encode())
files[name]={'before':hashlib.sha256(original).hexdigest() if original else None,'after':d.digest(candidate)}
(self.package/'dataset.json').write_text('{}')
(self.root/'commissioning/deploy_application.py').write_text('synthetic helper')
self.manifest={'scope':'historical_publication_only','installationId':'plant','files':files,
'existingDeploymentHelperSha256':d.digest(self.root/'commissioning/deploy_application.py'),
'datasetSha256':d.digest(self.package/'dataset.json'),'installerSha256':d.digest(SCRIPT)}
(self.package/'RELEASE.json').write_text(json.dumps(self.manifest))
self.context=patch.multiple(d,ROOT=self.root,PACKAGE=self.package);self.context.start()
self.calls=[]
def tearDown(self):self.context.stop();self.tmp.cleanup()
def fake_run(self,args,**kwargs):
self.calls.append(args)
if 'inspect' in args:out='sha256:test-old-image'
elif 'ps' in args:out='test-container'
elif 'exec' in args:out=json.dumps({'settingsUnchanged':True,'liveEnabled':False})
else:out=''
return subprocess.CompletedProcess(args,0,out,'')
def execute(self,fail_at=None):
def run(args,**kwargs):
out=self.fake_run(args,**kwargs)
if fail_at and fail_at in args:raise subprocess.CalledProcessError(1,args)
return out
def atomic(path,data,*args,**kwargs):path.write_bytes(data)
def backup(source,dest):self.calls.append(['test-backup']);dest.write_text('synthetic backup')
helpers=types.SimpleNamespace(atomic=atomic,backup_database=backup)
with patch.object(d.os,'geteuid',return_value=0),patch.object(d.os,'chown'),patch.object(d.subprocess,'run',side_effect=run),patch.dict(sys.modules,{'deploy_application':helpers}):
d.run('plant',True)
def report(self):return json.loads(next(self.root.glob('history-timing-releases/*/REPORT.json')).read_text())
def test_default_checks_only(self):
d.run('plant');self.assertEqual(self.calls,[])
self.assertFalse((self.root/'history-timing-releases').exists())
def test_order_tests_backup_then_only_v4_replacement(self):
self.execute()
test=next(i for i,c in enumerate(self.calls) if '/app/run_tests.py' in c)
backup=next(i for i,c in enumerate(self.calls) if c==['test-backup'])
up=next(i for i,c in enumerate(self.calls) if 'up' in c)
self.assertLess(test,backup);self.assertLess(backup,up)
self.assertTrue(all('license-portal' not in c and 'forecast-engine' not in c for c in self.calls))
self.assertEqual(self.report()['status'],'historical_timing_installed_no_actuation')
def test_failed_tests_restore_host_source_without_restart(self):
with self.assertRaises(subprocess.CalledProcessError):self.execute('/app/run_tests.py')
self.assertFalse(any('up' in c for c in self.calls))
for name,v in self.manifest['files'].items():self.assertEqual(d.digest(self.root/name),v['before'])
def test_failed_registration_rolls_image_back(self):
with self.assertRaises(subprocess.CalledProcessError):self.execute('exec')
self.assertIn('previous_image_restored',self.report()['rollback'])
self.assertTrue(any('--pull' in c and 'never' in c for c in self.calls))
def test_source_drift_refused_before_actions(self):
(self.root/d.TARGETS[0]).write_text('parallel change')
with self.assertRaises(ValueError):d.verify()
self.assertEqual(self.calls,[])
def test_source_already_installed_idempotent(self):
for name in d.TARGETS:(self.root/name).write_bytes((self.package/'source'/name).read_bytes())
self.assertEqual(d.verify()['scope'],'historical_publication_only')
self.execute()
self.assertEqual(self.report()['status'],'historical_timing_installed_no_actuation')
def test_no_production_selection_or_permission_in_bootstrap(self):
self.assertNotIn("'/settings'",d.BOOTSTRAP)
self.assertNotIn('/trial/arm',d.BOOTSTRAP)
self.assertIn("before['settings']==after['settings']",d.BOOTSTRAP)
self.assertIn("historyTimingVersion",d.BOOTSTRAP)
def test_target_image_contains_installer_test_dependency(self):
dockerfile=(SCRIPT.parents[1]/'Dockerfile').read_text()
self.assertIn('COPY commissioning/deploy_history_timing.py ./commissioning/deploy_history_timing.py',dockerfile)
if __name__=='__main__':unittest.main()