Files
Enelix-EMS/services/netplan-v4/release_preflight.py
T

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))