176 lines
9.6 KiB
Python
176 lines
9.6 KiB
Python
"""Install the reviewed server-side SHADOW integration; no Symcon/device writes.
|
|
|
|
Default: verify source and print scope only. --apply requires root. Build and test
|
|
all changed code before recreating services. Save old image/config references and
|
|
an integrity-checked online backup of the V4 DB. Does not enable native telemetry
|
|
or dispatch: operation ingress may still be awaiting installation on Symcon.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from uuid import UUID
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
SERVICES = ROOT.parent
|
|
PROJECT = SERVICES / 'prognosis-manager-enelix2'
|
|
PORTAL = SERVICES / 'license'
|
|
PREFLIGHT = ROOT / 'acceptance-reports/20261002T043021Z/REPORT.json'
|
|
MANIFEST = ROOT / 'INTEGRATED_SHADOW_SOURCE.json'
|
|
|
|
|
|
def verify_sources():
|
|
report = json.loads(PREFLIGHT.read_text())
|
|
if report.get('preflightChecksPassed') is not True:
|
|
raise ValueError('Expected successful earlier runtime preflight is missing')
|
|
expected = json.loads(MANIFEST.read_text())['files']
|
|
for relative, digest in expected.items():
|
|
path = SERVICES / relative
|
|
if not path.resolve().is_relative_to(SERVICES) or path.is_symlink() or not path.is_file():
|
|
raise ValueError('Invalid release source path: ' + relative)
|
|
if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
raise ValueError('Source changed since preparation: ' + relative)
|
|
from forecast_acceptance import verify_forecast_bundle
|
|
verify_forecast_bundle(ROOT/'acceptance/forecast-src', PROJECT/'forecast_engine')
|
|
from release_preflight import verify_native_bundle
|
|
verify_native_bundle()
|
|
return expected
|
|
|
|
|
|
def compose_groups():
|
|
return {
|
|
'v4': (ROOT, [ROOT/'compose.yaml'], ['netplan-v4']),
|
|
'forecast': (PROJECT, [PROJECT/'compose.yaml', ROOT/'compose.forecast-bridge.yaml'], ['api','forecast-engine','tariff-importer']),
|
|
'portal': (PORTAL, [PORTAL/'compose.yaml', ROOT/'compose.portal-bridge.yaml'], ['license-portal']),
|
|
}
|
|
|
|
|
|
def compose_args(files):
|
|
result=['docker','compose']
|
|
for path in files:result.extend(['-f',str(path)])
|
|
return result
|
|
|
|
|
|
def backup_database(source, destination):
|
|
if not source.exists():return {'exists':False}
|
|
src=sqlite3.connect(source.resolve().as_uri()+'?mode=ro',uri=True,timeout=15)
|
|
dst=sqlite3.connect(destination,timeout=15)
|
|
try:
|
|
src.backup(dst)
|
|
if dst.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
|
|
raise ValueError('V4 database backup failed integrity check')
|
|
finally:dst.close();src.close()
|
|
os.chmod(destination,0o640)
|
|
if os.geteuid()==0:os.chown(destination,1000,1000)
|
|
return {'exists':True,'path':str(destination),'sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'integrity':'ok'}
|
|
|
|
|
|
def save_json(path, value):
|
|
path.write_text(json.dumps(value,indent=2,allow_nan=False)+'\n')
|
|
os.chmod(path,0o640)
|
|
if os.geteuid()==0:os.chown(path,1000,1000)
|
|
|
|
|
|
def run(plant):
|
|
sources=verify_sources()
|
|
env=dict(os.environ,NETPLAN_V4_PLANTS=plant)
|
|
stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
|
folder=ROOT/'deployment-reports'/stamp
|
|
folder.mkdir(parents=True,exist_ok=False)
|
|
if os.geteuid()==0:
|
|
os.chown(folder.parent,1000,1000);os.chown(folder,1000,1000)
|
|
report={'createdAt':datetime.now(timezone.utc).isoformat(),'installationId':plant,
|
|
'scope':'server_shadow_only','liveEnabled':False,'productionReady':False,
|
|
'sourceHashes':sources,'steps':[],'before':{},'existingServiceRecreated':False}
|
|
def command(args,cwd=ROOT,capture=False,stdin=None,timeout=900):
|
|
result=subprocess.run(args,cwd=cwd,env=env,input=stdin,text=True,
|
|
stdout=subprocess.PIPE if capture else None,
|
|
stderr=subprocess.PIPE if capture else None,timeout=timeout,check=False)
|
|
if result.returncode:
|
|
raise RuntimeError('Step failed (exit '+str(result.returncode)+'): '+args[0])
|
|
return result.stdout if capture else ''
|
|
def step(name,args,cwd=ROOT,timeout=900):
|
|
print('\n=== '+name+' ===',flush=True)
|
|
command(args,cwd,timeout=timeout)
|
|
report['steps'].append(name);save_json(folder/'DEPLOYMENT.json',report)
|
|
try:
|
|
# Capture only selected nonsecret Docker fields, never the environment.
|
|
for group,(cwd,files,services) in compose_groups().items():
|
|
oldfiles=[files[0]]
|
|
items=[]
|
|
for service in services:
|
|
cid=command(compose_args(oldfiles)+['ps','-q',service],cwd,True).strip()
|
|
if not cid or '\n' in cid:raise ValueError('Expected exactly one existing container: '+service)
|
|
image=command(['docker','inspect','--format','{{.Image}}',cid],cwd,True).strip()
|
|
configs=command(['docker','inspect','--format','{{index .Config.Labels "com.docker.compose.project.config_files"}}',cid],cwd,True).strip()
|
|
items.append({'service':service,'containerId':cid,'imageId':image,'previousComposeFiles':configs})
|
|
report['before'][group]=items
|
|
save_json(folder/'DEPLOYMENT.json',report)
|
|
step('review optional source installer',[sys.executable,str(ROOT/'install_hooks.py')])
|
|
# All new forecasting tests run offline in a disposable Python 3.11 image.
|
|
step('build forecast acceptance image',['docker','build','-f',str(ROOT/'acceptance/Dockerfile.forecast'),'-t','enelix-forecast-candidate-check:local',str(ROOT/'acceptance')])
|
|
step('forecast candidate offline tests',['docker','run','--rm','--network','none','--read-only','--user','1000:1000','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:rw,noexec,nosuid,size=64m','enelix-forecast-candidate-check:local'])
|
|
step('V4 and bridge target tests',[sys.executable,str(ROOT/'deploy_shadow.py'),'--plant',plant,'--test-only'])
|
|
verify_sources()
|
|
from install_hooks import plan,apply
|
|
receipt=apply(plan())
|
|
report['sourceInstallReceipt']=str(receipt) if receipt else None
|
|
step('portal syntax',['node','--check',str(PORTAL/'server.mjs')])
|
|
# Build every production-shaped candidate before changing a running container.
|
|
cwd,files,svcs=compose_groups()['forecast']
|
|
step('build server-side images',compose_args(files)+['build']+svcs,cwd)
|
|
report['v4DatabaseBackup']=backup_database(ROOT/'data/netplan-v4.sqlite',folder/'netplan-v4-before.sqlite')
|
|
report['existingServiceRecreated']=True
|
|
save_json(folder/'DEPLOYMENT.json',report)
|
|
for group,(cwd,files,svcs) in compose_groups().items():
|
|
step('start shadow integration '+group,compose_args(files)+['up','-d','--no-deps','--no-build','--wait','--wait-timeout','180']+svcs,cwd,timeout=300)
|
|
cwd,files,_=compose_groups()['v4']
|
|
# Check shadow guard and report incoming types without disclosing payloads.
|
|
probe='''import json,os,sqlite3,urllib.request
|
|
from datetime import datetime,timezone
|
|
plant=os.environ['NETPLAN_V4_PLANTS']
|
|
health=json.loads(urllib.request.urlopen('http://127.0.0.1:9100/health',timeout=5).read())
|
|
assert health.get('mode')=='shadow' and health.get('liveEnabled') is False
|
|
con=sqlite3.connect('file:/data/netplan-v4.sqlite?mode=ro',uri=True)
|
|
rows=con.execute('SELECT kind,count(*) FROM planner_inputs WHERE plant=? GROUP BY kind',(plant,)).fetchall()
|
|
status=con.execute('SELECT status FROM planner_run_status WHERE plant=?',(plant,)).fetchone()
|
|
con.close()
|
|
print(json.dumps({'checkedAt':datetime.now(timezone.utc).isoformat(),'health':health,'inputEventsByKind':dict(rows),'lastRunStatus':status[0] if status else None,'nativeSenderInstalledByThisCommand':False}))
|
|
'''
|
|
report['readiness']=json.loads(command(compose_args(files)+['exec','-T','netplan-v4','python','-B','-'],cwd,True,probe,60))
|
|
report['status']='server_shadow_installed'
|
|
report['remaining']='Native operation sender, plant-level initialization and real shadow-plan acceptance still required; no live dispatch installed'
|
|
except Exception as error:
|
|
report['status']='failed';report['errorType']=type(error).__name__;report['error']=str(error)[:500]
|
|
report['remaining']='Inspect completed steps and before-image references. No automatic database restore or device changes.'
|
|
raise
|
|
finally:
|
|
save_json(folder/'DEPLOYMENT.json',report)
|
|
print('\nDEPLOYMENT REPORT: '+str(folder/'DEPLOYMENT.json'),flush=True)
|
|
print('SERVER SHADOW ONLY. Symcon, device permissions and actuators were not modified.',flush=True)
|
|
print(json.dumps(report['readiness'],indent=2))
|
|
print('Native operating-state input may still be missing; this is NOT production commissioning.')
|
|
|
|
|
|
if __name__=='__main__':
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--plant',required=True,type=lambda p:str(UUID(p)))
|
|
parser.add_argument('--apply',action='store_true')
|
|
args=parser.parse_args()
|
|
if args.plant!='e3a08f9e-af12-4695-99bd-8b51c0520021':
|
|
raise SystemExit('This first integration rollout is allowlisted for Lihrenmoos only.')
|
|
if not args.apply:
|
|
files=verify_sources()
|
|
print('CHECK ONLY:',len(files),'reviewed source files. No running service changed.')
|
|
for group,(_,_,svcs) in compose_groups().items():print(group,', '.join(svcs))
|
|
else:
|
|
if os.geteuid()!=0:raise SystemExit('Run as root; do not change Docker socket access.')
|
|
try:run(args.plant)
|
|
except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired) as error:
|
|
raise SystemExit('Deployment stopped: '+str(error))
|