"""Deploy the integrated measurement/training application, NOT actuator permission. Only V4 and the portal are recreated. Existing legacy forecasts, tariffs, Symcon and battery dispatch are unchanged. Source-only checks are the default; --apply is explicit. """ from pathlib import Path from datetime import datetime, timezone from uuid import UUID import argparse,hashlib,json,os,sqlite3,subprocess,tempfile ROOT=Path(__file__).resolve().parents[1] PORTAL=ROOT.parent/'license' MANIFEST=Path(__file__).with_name('application-source')/'RELEASE.json' DATASET=Path(__file__).with_name('application-source')/'server-dataset.json' PORTAL_FILES={'integrations/netplan-v4-bridge.mjs':'netplan-v4-bridge.mjs','gui/netplan-v4.js':'public/netplan-v4.js'} def digest(p):return hashlib.sha256(p.read_bytes()).hexdigest() def verify(): m=json.loads(MANIFEST.read_text()) if m.get('scope')!='integrated_measurement_application' or not m.get('sourceHashes'): raise ValueError('Unexpected application release manifest') for n,h in m['sourceHashes'].items(): p=ROOT/n if Path(n).is_absolute() or '..' in Path(n).parts or p.is_symlink() or not p.is_file() or digest(p)!=h: raise ValueError('Source drift: '+n) for n,old in m['portalBefore'].items(): if n not in PORTAL_FILES.values():raise ValueError('Unexpected portal target') p=PORTAL/n if p.is_symlink() or not p.is_file():raise ValueError('Portal target changed') source=next(s for s,t in PORTAL_FILES.items() if t==n) if digest(p) not in (old,m['sourceHashes'][source]):raise ValueError('Concurrent portal change; not overwritten') return m def atomic(path,data,mode=0o644): if path.is_symlink():raise ValueError('Symlink refused') fd,name=tempfile.mkstemp(prefix='.v4-app-',dir=path.parent) try: with os.fdopen(fd,'wb') as f:f.write(data);f.flush();os.fsync(f.fileno()) os.chmod(name,mode);os.replace(name,path) finally: if os.path.exists(name):os.unlink(name) def backup_database(source,destination): if not source.is_file() or source.is_symlink() or destination.exists():raise ValueError('Explicit existing database and new backup path required') with sqlite3.connect(source.as_uri()+'?mode=ro',uri=True) as a,sqlite3.connect(destination) as b: a.backup(b) if b.execute('PRAGMA integrity_check').fetchone()[0]!='ok':raise ValueError('Backup failed') destination.chmod(0o600) BOOTSTRAP='''import json,os,urllib.request,sys p=json.load(sys.stdin) base='http://127.0.0.1:9100/internal/v2/prognosis/'+p['plant']+'/planner' h={'X-Enelix-Service-Token':os.environ['PROGNOSIS_SERVICE_TOKEN'],'Content-Type':'application/json'} req=urllib.request.Request(base+'/datasets/'+p['dataset']['datasetId'],data=json.dumps(p['dataset']).encode(),headers=h,method='PUT') receipt=json.load(urllib.request.urlopen(req,timeout=10)) state=json.load(urllib.request.urlopen(urllib.request.Request(base,headers=h),timeout=10)) assert state['liveEnabled'] is False print(json.dumps({'dataset':receipt,'existingForecastSource':state['settings']['forecastSource'],'liveEnabled':False})) ''' def run(plant,apply=False): m=verify() if plant!=m['installationId']:raise ValueError('Use the prepared installation-specific mapping') if not apply: print('APPLICATION SOURCE CHECK PASSED:',len(m['sourceHashes']),'files. No deployment.');return if os.geteuid()!=0:raise ValueError('Run as root; do not widen Docker permissions') folder=ROOT/'application-releases'/datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') folder.mkdir(parents=True,mode=0o700) env={**os.environ,'NETPLAN_V4_PLANTS':plant} compose=['docker','compose','-f',str(ROOT/'compose.yaml')] portal=['docker','compose','-f',str(PORTAL/'compose.yaml'),'-f',str(ROOT/'compose.portal-bridge.yaml')] result={'scope':'integrated_measurement_application','startedAt':datetime.now(timezone.utc).isoformat(), 'liveEnabled':False,'productionCommissioned':False,'steps':[],'sourceHashes':m['sourceHashes']} changed={};old_image=None;v4_replaced=False;portal_replaced=False def cmd(args,timeout=180,capture=False,input=None): return subprocess.run(args,cwd=ROOT,env=env,check=True,timeout=timeout,text=True,input=input, stdout=subprocess.PIPE if capture else None,stderr=subprocess.PIPE if capture else None) try: ids=cmd(compose+['ps','-q','netplan-v4'],capture=True).stdout.split() if len(ids)!=1:raise ValueError('Expected existing V4 service') old_image=cmd(['docker','inspect','--format','{{.Image}}',ids[0]],capture=True).stdout.strip() result['previousV4Image']=old_image cmd(compose+['build','netplan-v4'],timeout=900) cmd(compose+['run','--rm','--no-deps','--entrypoint','python','netplan-v4','/app/run_tests.py'],timeout=240) cmd(['node','--test',str(ROOT/'tests/portal.test.mjs')]) cmd(['node','--check',str(ROOT/'gui/netplan-v4.js')]) result['steps'].append('target_python_and_portal_tests_passed');verify() backup_database(ROOT/'data/netplan-v4.sqlite',folder/'before.sqlite') result['steps'].append('consistent_database_backup') for source,target in PORTAL_FILES.items(): p=PORTAL/target;old=p.read_bytes();new=(ROOT/source).read_bytes() if old==new:continue if hashlib.sha256(old).hexdigest()!=m['portalBefore'][target]:raise ValueError('Concurrent portal change') dest=folder/'portal-before'/target;dest.parent.mkdir(parents=True,exist_ok=True);dest.write_bytes(old) atomic(p,new);changed[target]=(old,new) v4_replaced=True;cmd(compose+['up','-d','--no-deps','--no-build','--wait','netplan-v4']) # Recreate rather than restart: atomic replacement of a file bind mount needs a new mount. portal_replaced=True;cmd(portal+['up','-d','--no-deps','--no-build','--force-recreate','--wait','license-portal']) payload=json.dumps({'plant':plant,'dataset':json.loads(DATASET.read_text())}) receipt=cmd(compose+['exec','-T','netplan-v4','python','-c',BOOTSTRAP],capture=True,input=payload) result['application']=json.loads(receipt.stdout) result['status']='application_deployed_no_actuator_permission' result['steps'].append('configured_dataset_and_health_verified') except Exception as e: result['status']='needs_review';result['errorType']=type(e).__name__ restored=[] for target,(old,new) in changed.items(): p=PORTAL/target if p.read_bytes()==new:atomic(p,old);restored.append(target) result['restoredPortalFiles']=restored try: if v4_replaced and old_image: rollback=folder/'rollback.yaml';rollback.write_text('services:\n netplan-v4:\n image: '+old_image+'\n') cmd(compose+['-f',str(rollback),'up','-d','--no-deps','--no-build','--pull','never','--wait','netplan-v4']) if portal_replaced:cmd(portal+['up','-d','--no-deps','--no-build','--force-recreate','--wait','license-portal']) result['rollback']='previous_runtime_restored_additive_data_retained' except Exception:result['rollback']='manual_review_required' raise finally: result['finishedAt']=datetime.now(timezone.utc).isoformat() report=folder/'REPORT.json';report.write_text(json.dumps(result,indent=2)+'\n') uid=ROOT.stat().st_uid;gid=ROOT.stat().st_gid os.chown(folder,uid,gid);os.chown(folder.parent,uid,gid);os.chown(report,uid,gid);report.chmod(0o640) print('APPLICATION RELEASE REPORT:',report) print('V4 data/model application and portal updated. Install manager data integration separately. No V4 actuation enabled.') if __name__=='__main__': p=argparse.ArgumentParser(description=__doc__);p.add_argument('--plant',required=True,type=lambda v:str(UUID(v)));p.add_argument('--apply',action='store_true');a=p.parse_args() try:run(a.plant,a.apply) except Exception as e:raise SystemExit('Stopped: '+type(e).__name__+'. See the application release report.')