feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""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.')
|
||||
Reference in New Issue
Block a user