132 lines
8.0 KiB
Python
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.')
|