feat(v4): consolidate audited data recovery economic replay and optional archival
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Install the reviewed V4 release candidate as one transaction-like deployment.
|
||||
|
||||
Tests run in an isolated candidate image BEFORE replacing any running service.
|
||||
No device control permission, forecast-source switch, telemetry rewrite or cursor
|
||||
jump. Exactly proven numeric representations are registered for backlog recovery.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import argparse,hashlib,json,os,subprocess,sys,tempfile
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
PACKAGE=ROOT/'commissioning/unified-release'
|
||||
GUI=ROOT.parent/'license/public/netplan-v4.js'
|
||||
|
||||
|
||||
def digest(p):
|
||||
if p.is_symlink():raise ValueError('Symlink refused')
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else None
|
||||
|
||||
|
||||
def verify():
|
||||
m=json.loads((PACKAGE/'RELEASE.json').read_text())
|
||||
if m.get('scope')!='unified_v4_candidate' or not m.get('files'):raise ValueError('Unexpected release')
|
||||
if digest(Path(__file__).resolve())!=m['installerSha256']:raise ValueError('Installer changed')
|
||||
for n,v in m['files'].items():
|
||||
if Path(n).is_absolute() or '..' in Path(n).parts:raise ValueError('Unsafe release path')
|
||||
if digest(PACKAGE/'context'/n)!=v['after']:raise ValueError('Candidate drift: '+n)
|
||||
if digest(ROOT/n) not in (v['before'],v['after']):raise ValueError('Concurrent runtime source edit: '+n)
|
||||
if digest(GUI) not in (m['guiBefore'],m['files']['gui/netplan-v4.js']['after']):raise ValueError('Concurrent GUI edit')
|
||||
if digest(PACKAGE/'mapping-proof.json')!=m['proofSha256']:raise ValueError('Compatibility proof changed')
|
||||
if digest(ROOT/'commissioning/deploy_application.py')!=m['backupHelperSha256']:raise ValueError('Backup helper changed')
|
||||
return m
|
||||
|
||||
|
||||
BOOTSTRAP=r'''import json,os,sys,urllib.request
|
||||
x=json.load(sys.stdin);base='http://127.0.0.1:9100'
|
||||
health=json.load(urllib.request.urlopen(base+'/health',timeout=5))
|
||||
assert health.get('applicationRelease')=='unified-rc1' and health.get('liveEnabled') is False
|
||||
headers={'X-Enelix-Service-Token':os.environ['PROGNOSIS_SERVICE_TOKEN'],'Content-Type':'application/json'}
|
||||
url=base+'/internal/v2/prognosis/'+x['plant']+'/planner'
|
||||
def get():return json.load(urllib.request.urlopen(urllib.request.Request(url,headers=headers),timeout=20))
|
||||
before=get()
|
||||
receipt={'status':'not_registered_requires_explicit_approval'}
|
||||
if x['approveMapping']:
|
||||
request=urllib.request.Request(url+'/datasets/'+x['dataset']+'/mapping-compatibility',data=json.dumps(x['proof']).encode(),headers=headers,method='PUT')
|
||||
receipt=json.load(urllib.request.urlopen(request,timeout=15))
|
||||
after=get();assert before['settings']==after['settings'] and after['liveEnabled'] is False
|
||||
print(json.dumps({'health':health,'mappingRecovery':receipt,'settingsUnchanged':True,'forecastSource':after['settings']['forecastSource'],'economicReplayConnected':after['economicComparison']['connected'],'dataSets':[{'datasetId':d['datasetId'],'records':d['records'],'lastCapture':d['lastCapture'],'lastReceived':d.get('lastReceived'),'status':d['status']} for d in after['dataPipeline']['datasets']]}))
|
||||
'''
|
||||
|
||||
|
||||
def run(plant,apply=False,approve_mapping=False):
|
||||
m=verify()
|
||||
if plant!=m['installationId']:raise ValueError('Wrong prepared installation')
|
||||
if not apply:
|
||||
print('UNIFIED RELEASE SOURCE CHECK PASSED; no installation.');return
|
||||
if os.geteuid()!=0:raise ValueError('Run as root; do not widen Docker socket permissions')
|
||||
from deploy_application import atomic,backup_database
|
||||
folder=ROOT/'unified-releases'/datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ');folder.mkdir(parents=True,mode=0o700)
|
||||
report={'scope':'unified_v4_candidate','releaseId':m['releaseId'],'startedAt':datetime.now(timezone.utc).isoformat(),'liveEnabled':False,'productionCommissioned':False,'steps':[]}
|
||||
compose=['docker','compose','-f',str(ROOT/'compose.yaml')];env=dict(os.environ)
|
||||
def cmd(args,timeout=180,capture=False,data=None):
|
||||
return subprocess.run(args,cwd=ROOT,env=env,check=True,timeout=timeout,text=True,input=data,stdout=subprocess.PIPE if capture else None,stderr=subprocess.PIPE if capture else None)
|
||||
changed={};old_gui=None;new_gui=None;replaced=False;old_image=None
|
||||
try:
|
||||
# Read only this non-secret allowlist; never print or persist the complete environment.
|
||||
env['NETPLAN_V4_PLANTS']=plant
|
||||
ids=cmd(compose+['ps','-q','netplan-v4'],capture=True).stdout.split()
|
||||
if len(ids)!=1:raise ValueError('Expected existing V4 service')
|
||||
allowed=cmd(['docker','exec',ids[0],'python','-c',"import os;print(os.getenv('NETPLAN_V4_PLANTS',''))"],capture=True).stdout.strip()
|
||||
if plant not in allowed.split(','):raise ValueError('Installation not in running allowlist')
|
||||
env['NETPLAN_V4_PLANTS']=allowed
|
||||
old_image=cmd(['docker','inspect','--format','{{.Image}}',ids[0]],capture=True).stdout.strip();report['previousImage']=old_image
|
||||
tag='enelix-netplan-v4:rc-'+m['releaseId'][:16]
|
||||
cmd(['docker','build','-t',tag,str(PACKAGE/'context')],timeout=900)
|
||||
candidate=cmd(['docker','image','inspect','--format','{{.Id}}',tag],capture=True).stdout.strip()
|
||||
cmd(['docker','run','--rm','--network','none','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges','--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=256m','--entrypoint','python',candidate,'/app/run_tests.py'],timeout=600)
|
||||
cmd(['node','--test',str(PACKAGE/'context/tests/portal.test.mjs')]);cmd(['node','--check',str(PACKAGE/'context/gui/netplan-v4.js')])
|
||||
report['steps'].append('isolated_candidate_tests_passed');verify()
|
||||
backup_database(ROOT/'data/netplan-v4.sqlite',folder/'before.sqlite');report['steps'].append('consistent_backup')
|
||||
for name,v in m['files'].items():
|
||||
target=ROOT/name;after=(PACKAGE/'context'/name).read_bytes();before=target.read_bytes() if target.is_file() else None
|
||||
if before==after:continue
|
||||
if digest(target)!=v['before']:raise ValueError('Source changed during tests')
|
||||
backup=folder/'source-before'/name;backup.parent.mkdir(parents=True,exist_ok=True)
|
||||
if before is not None:backup.write_bytes(before)
|
||||
target.parent.mkdir(parents=True,exist_ok=True);atomic(target,after);changed[name]=(before,after)
|
||||
override=folder/'candidate.yaml';override.write_text('services:\n netplan-v4:\n image: '+candidate+'\n')
|
||||
rollback=folder/'rollback.yaml';rollback.write_text('services:\n netplan-v4:\n image: '+old_image+'\n')
|
||||
replaced=True;cmd(compose+['-f',str(override),'up','-d','--no-deps','--no-build','--wait','netplan-v4'],timeout=180)
|
||||
data=json.dumps({'plant':plant,'dataset':'lihrenmoos-physical-v1','proof':json.loads((PACKAGE/'mapping-proof.json').read_text()),'approveMapping':approve_mapping})
|
||||
result=cmd(compose+['exec','-T','netplan-v4','python','-c',BOOTSTRAP],capture=True,data=data)
|
||||
report['application']=json.loads(result.stdout)
|
||||
old_gui=GUI.read_bytes();new_gui=(PACKAGE/'context/gui/netplan-v4.js').read_bytes()
|
||||
if digest(GUI) not in (m['guiBefore'],m['files']['gui/netplan-v4.js']['after']):raise ValueError('Concurrent portal UI edit')
|
||||
(folder/'gui-before.js').write_bytes(old_gui);atomic(GUI,new_gui)
|
||||
report['steps'].append('audited_backlog_compatibility_and_application_verified');report['candidateImage']=candidate
|
||||
report['status']='unified_candidate_installed_no_actuation'
|
||||
except Exception as exc:
|
||||
report['status']='needs_review';report['errorType']=type(exc).__name__
|
||||
if old_gui is not None and GUI.read_bytes()==new_gui:atomic(GUI,old_gui)
|
||||
for name,(before,after) in reversed(list(changed.items())):
|
||||
target=ROOT/name
|
||||
if target.read_bytes()!=after:continue
|
||||
if before is None:target.unlink()
|
||||
else:atomic(target,before)
|
||||
if replaced and old_image:
|
||||
try:
|
||||
cmd(compose+['-f',str(folder/'rollback.yaml'),'up','-d','--no-deps','--no-build','--pull','never','--wait','netplan-v4'])
|
||||
report['rollback']='previous_image_restored_additive_audit_tables_retained'
|
||||
except Exception:report['rollback']='manual_review_required'
|
||||
raise
|
||||
finally:
|
||||
report['finishedAt']=datetime.now(timezone.utc).isoformat();p=folder/'REPORT.json';p.write_text(json.dumps(report,indent=2)+'\n')
|
||||
uid,gid=ROOT.stat().st_uid,ROOT.stat().st_gid
|
||||
for x in (folder.parent,folder,p):os.chown(x,uid,gid)
|
||||
p.chmod(0o640);print('UNIFIED RELEASE REPORT:',p)
|
||||
print('V4 candidate and planner UI installed. Mapping equivalence is activated only with explicit approval. No data/cursor rewrite and no device-control grant.')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--plant',required=True);p.add_argument('--apply',action='store_true');p.add_argument('--approve-numeric-mapping-compatibility',action='store_true',help='Explicitly approve only the audited 100 versus 100.0 mapping equivalence; never rewrite original records');a=p.parse_args()
|
||||
try:run(a.plant,a.apply,a.approve_numeric_mapping_compatibility)
|
||||
except Exception as exc:raise SystemExit('Stopped: '+type(exc).__name__+'. Review UNIFIED RELEASE REPORT; no actuator permission enabled.')
|
||||
Reference in New Issue
Block a user