"""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.')