129 lines
8.2 KiB
Python
129 lines
8.2 KiB
Python
"""One consolidated ROOT preflight; tests and read-only acquisition, NEVER deploy.
|
|
|
|
Builds disposable test images, executes Python and native-PHP conversion tests,
|
|
then reads actual raw forecast/input diagnostics using the existing container.
|
|
No docker up/restart, no live-setting change, no training or actuator call.
|
|
A passing preflight is NOT a production release or full plant acceptance.
|
|
"""
|
|
from datetime import datetime,timezone
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
from uuid import UUID
|
|
from forecast_acceptance import verify_forecast_bundle
|
|
|
|
ROOT=Path(__file__).resolve().parent
|
|
REPO=Path('/srv/agent/repos/Enelix-EMS')
|
|
PROJECT=Path('/home/agent/services/prognosis-manager-enelix2')
|
|
|
|
|
|
def command_list(plant):
|
|
return [
|
|
('python311_and_portal',[sys.executable,str(ROOT/'deploy_shadow.py'),'--plant',plant,'--test-only'],ROOT,900),
|
|
('php_image',['docker','build','-f',str(ROOT/'acceptance/Dockerfile.php'),'-t','enelix-netplan-v4-php-check:local',str(ROOT/'acceptance')],ROOT,900),
|
|
('php83_offline',['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=32m','enelix-netplan-v4-php-check:local'],ROOT,120),
|
|
('forecast_candidate_image',['docker','build','-f',str(ROOT/'acceptance/Dockerfile.forecast'),'-t','enelix-forecast-candidate-check:local',str(ROOT/'acceptance')],ROOT,900),
|
|
('forecast_candidate_python311',['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'],ROOT,180),
|
|
('gui_syntax',['node','--check',str(ROOT/'gui/netplan-v4.js')],ROOT,30),
|
|
]
|
|
|
|
|
|
def source_manifest():
|
|
selected=list((ROOT/'netplan_v4').glob('*.py'))+list((ROOT/'tests').glob('*.py'))+[ROOT/'gui/netplan-v4.js',ROOT/'acceptance/read_native_forecasts.py',ROOT/'forecast_acceptance.py',ROOT/'acceptance/check_forecast.py',ROOT/'acceptance/Dockerfile.forecast',ROOT/'acceptance/forecast-src/SOURCE_MANIFEST.json']
|
|
selected += [ROOT/'release_preflight.py', ROOT/'Dockerfile', ROOT/'acceptance/Dockerfile.php', ROOT/'acceptance/php-src/SOURCE_MANIFEST.json']
|
|
return {str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(selected)}
|
|
|
|
|
|
def verify_native_bundle():
|
|
bundle=ROOT/'acceptance/php-src'
|
|
manifest=json.loads((bundle/'SOURCE_MANIFEST.json').read_text())
|
|
for relative,expected in manifest.items():
|
|
if Path(relative).is_absolute() or '..' in Path(relative).parts:raise ValueError('Unsafe source manifest')
|
|
for p in (bundle/relative,REPO/relative):
|
|
if not p.is_file() or p.is_symlink() or hashlib.sha256(p.read_bytes()).hexdigest()!=expected:
|
|
raise ValueError('Native source changed after staging; review and refresh acceptance bundle')
|
|
|
|
|
|
def save(path,value):
|
|
raw=json.dumps(value,indent=2,allow_nan=False)+'\n'
|
|
with path.open('w') as output:output.write(raw)
|
|
os.chmod(path,0o640)
|
|
if os.geteuid()==0:os.chown(path,1000,1000)
|
|
|
|
|
|
def run(plant):
|
|
verify_native_bundle()
|
|
forecast_hashes=verify_forecast_bundle(ROOT/'acceptance/forecast-src',PROJECT/'forecast_engine')
|
|
stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
|
reports=ROOT/'acceptance-reports'/stamp;reports.mkdir(parents=True,exist_ok=False)
|
|
if os.geteuid()==0:
|
|
os.chown(reports.parent,1000,1000);os.chown(reports,1000,1000)
|
|
report={'createdAt':datetime.now(timezone.utc).isoformat(),'installationId':plant,'productionReady':False,'deploymentPerformed':False,'tests':{},'sourceHashes':source_manifest(),'remainingWork':['Native source installation and complete Symcon runtime test','Restore/validate real forecast telemetry and nonzero load profiles','Wire productive historical family replay and model promotion','Verify base-load/SDL accounting and physical meter metadata','Implement and test live V4 plan execution and fallback before activation']}
|
|
report['forecastCandidateSourceHashes']=forecast_hashes
|
|
report['forecastCandidateRuntimeTestIsOffline']=True
|
|
env=dict(os.environ,NETPLAN_V4_PLANTS=plant)
|
|
success=True
|
|
for name,command,cwd,timeout in command_list(plant):
|
|
print('\n=== '+name+' ===',flush=True)
|
|
try:
|
|
completed=subprocess.run(command,cwd=cwd,env=env,timeout=timeout,check=False)
|
|
code=completed.returncode
|
|
except subprocess.TimeoutExpired:code=124
|
|
report['tests'][name]={'exitCode':code,'passed':code==0}
|
|
if code:
|
|
success=False;break
|
|
if success:
|
|
print('\n=== Existing forecast engine: READ ONLY ===',flush=True)
|
|
command=['docker','compose','-f',str(PROJECT/'compose.yaml'),'exec','-T','-e','ENELIX_ACCEPTANCE_PLANT='+plant,'forecast-engine','python','-B','-']
|
|
try:
|
|
result=subprocess.run(command,cwd=PROJECT,env=env,input=(ROOT/'acceptance/read_native_forecasts.py').read_text(),text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120,check=False)
|
|
if result.returncode or len(result.stdout)>8000000:raise RuntimeError('Probe execution failed or oversized')
|
|
probe=json.loads(result.stdout)
|
|
save(reports/'native-inputs.json',probe)
|
|
report['nativeInputStatus']=probe.get('status')
|
|
report['forecastSummary']=probe.get('forecastSummary',{})
|
|
report['dataBlockers']=[]
|
|
for key,value in report['forecastSummary'].items():
|
|
if key in ('prog_var_2','prog_var_11','prog_var_22') and (not value.get('points') or value.get('allZero')):
|
|
report['dataBlockers'].append(key+': missing or unconfirmed all-zero load forecast')
|
|
recent=probe.get('inputFrames',{}).get('df_recent_raw',{}).get('columns',{}).get('Hausverbrauch',{}).get('lastFiniteAt')
|
|
if not recent or (datetime.now(timezone.utc)-datetime.fromisoformat(recent)).total_seconds()>1800:
|
|
report['dataBlockers'].append('Recent raw load telemetry missing or older than 30 minutes')
|
|
if probe.get('status')!='read_only_acquired' or report['dataBlockers']:success=False
|
|
except (ValueError,RuntimeError,subprocess.TimeoutExpired) as error:
|
|
report['nativeInputStatus']='failed';report['nativeInputErrorType']=type(error).__name__;success=False
|
|
try:
|
|
verify_native_bundle()
|
|
report['sourceStableDuringTests']=(source_manifest()==report['sourceHashes'] and verify_forecast_bundle(ROOT/'acceptance/forecast-src',PROJECT/'forecast_engine')==forecast_hashes)
|
|
except (OSError,ValueError):
|
|
report['sourceStableDuringTests']=False
|
|
if not report['sourceStableDuringTests']:
|
|
report.setdefault('dataBlockers',[]).append('Source changed during checks; results cannot certify current candidate')
|
|
success=False
|
|
report['preflightChecksPassed']=success
|
|
save(reports/'REPORT.json',report)
|
|
save(ROOT/'acceptance-reports'/'LATEST.json',{'report':str(reports/'REPORT.json')})
|
|
print('\nPRECHECK '+('PASSED' if success else 'NEEDS REVIEW')+'; NOT a production release.')
|
|
print('REPORT: '+str(reports/'REPORT.json'))
|
|
for key,value in report.get('forecastSummary',{}).items():print(key,json.dumps(value))
|
|
for blocker in report.get('dataBlockers',[]):print('DATA BLOCKER:',blocker)
|
|
print('Existing containers, Symcon settings, timers and actuators unchanged.')
|
|
return 0 if success else 1
|
|
|
|
if __name__=='__main__':
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--plant',required=True,type=lambda v:str(UUID(v)))
|
|
parser.add_argument('--show-commands',action='store_true')
|
|
args=parser.parse_args()
|
|
if args.show_commands:
|
|
for name,command,cwd,timeout in command_list(args.plant):print(name,json.dumps(command))
|
|
print('Native input probe: read-only docker compose exec forecast-engine python with reviewed stdin script')
|
|
else:
|
|
if os.geteuid()!=0:raise SystemExit('Run as root; do not alter Docker socket permissions.')
|
|
try:raise SystemExit(run(args.plant))
|
|
except (OSError,ValueError) as error:raise SystemExit('Preflight stopped safely: '+str(error))
|