Files
Enelix-EMS/services/netplan-v4/commissioning/deploy_application.py
T

132 lines
8.0 KiB
Python

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