fix(history): model sparse publication without relaxing live freshness
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Install the tested historical-publication correction in the V4 service only.
|
||||
|
||||
Default is source validation. --apply requires root. No Symcon changes, polling,
|
||||
portal restart, old-data rewrite, forecast-source switch or actuator permission.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE = Path(__file__).with_name('history-timing-source')
|
||||
TARGETS = ('Dockerfile', 'netplan_v4/history_timing.py', 'netplan_v4/measurement_pipeline.py',
|
||||
'tests/test_history_timing.py', 'tests/test_history_timing_deployment.py')
|
||||
|
||||
|
||||
def digest(path):
|
||||
if path.is_symlink():
|
||||
raise ValueError('Source symlink refused')
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
|
||||
|
||||
|
||||
def verify():
|
||||
manifest = json.loads((PACKAGE/'RELEASE.json').read_text())
|
||||
if manifest.get('scope') != 'historical_publication_only' or tuple(manifest.get('files', {})) != TARGETS:
|
||||
raise ValueError('Unexpected timing release scope')
|
||||
for name, versions in manifest['files'].items():
|
||||
if digest(PACKAGE/'source'/name) != versions['after']:
|
||||
raise ValueError('Timing candidate changed: '+name)
|
||||
if digest(ROOT/name) not in (versions['before'], versions['after']):
|
||||
raise ValueError('Concurrent application change: '+name)
|
||||
if digest(PACKAGE/'dataset.json') != manifest['datasetSha256']:
|
||||
raise ValueError('Dataset candidate changed')
|
||||
if digest(ROOT/'commissioning/deploy_application.py') != manifest['existingDeploymentHelperSha256']:
|
||||
raise ValueError('Existing deployment helper changed')
|
||||
if digest(Path(__file__).resolve()) != manifest['installerSha256']:
|
||||
raise ValueError('Timing installer changed')
|
||||
return manifest
|
||||
|
||||
|
||||
BOOTSTRAP = r'''import json,os,sys,time,urllib.request
|
||||
payload=json.load(sys.stdin)
|
||||
base='http://127.0.0.1:9100/internal/v2/prognosis/'+payload['plant']+'/planner'
|
||||
headers={'X-Enelix-Service-Token':os.environ['PROGNOSIS_SERVICE_TOKEN'],'Content-Type':'application/json'}
|
||||
def get():
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(base,headers=headers),timeout=10))
|
||||
before=get()
|
||||
assert before['dataPipeline'].get('historyTimingVersion')==1
|
||||
assert before['liveEnabled'] is False
|
||||
req=urllib.request.Request(base+'/datasets/'+payload['dataset']['datasetId'],
|
||||
data=json.dumps(payload['dataset']).encode(),headers=headers,method='PUT')
|
||||
receipt=json.load(urllib.request.urlopen(req,timeout=10))
|
||||
after=get()
|
||||
assert before['settings']==after['settings'] and after['liveEnabled'] is False
|
||||
view=next(d for d in after['dataPipeline']['datasets'] if d['datasetId']==payload['dataset']['datasetId'])
|
||||
print(json.dumps({'dataset':receipt,'originalObservationDataset':view['observationDatasetId'],
|
||||
'availableRecords':view['records'],'pipelineStatus':view['status'],
|
||||
'settingsUnchanged':True,'forecastSource':after['settings']['forecastSource'],
|
||||
'liveEnabled':False}))
|
||||
'''
|
||||
|
||||
|
||||
def run(plant, apply=False):
|
||||
manifest = verify()
|
||||
if plant != manifest['installationId']:
|
||||
raise ValueError('Use the reviewed installation')
|
||||
if not apply:
|
||||
print('HISTORY TIMING SOURCE CHECK PASSED; no runtime changes.')
|
||||
return
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError('Run as root; do not change Docker permissions')
|
||||
# Reuse the reviewed atomic-write and consistent-backup implementation.
|
||||
from deploy_application import atomic, backup_database
|
||||
folder = ROOT/'history-timing-releases'/datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||
folder.mkdir(parents=True, mode=0o700)
|
||||
result={'scope':'historical_publication_only','startedAt':datetime.now(timezone.utc).isoformat(),
|
||||
'steps':[],'liveEnabled':False,'symconChanged':False,'pollingChanged':False,
|
||||
'sourceFreshnessLimitsChanged':False,'forecastSelectionChanged':False}
|
||||
compose=['docker','compose','-f',str(ROOT/'compose.yaml')]
|
||||
env={**os.environ,'NETPLAN_V4_PLANTS':plant}
|
||||
def cmd(args, timeout=180, capture=False, data=None):
|
||||
return subprocess.run(args,cwd=ROOT,env=env,check=True,timeout=timeout,text=True,input=data,
|
||||
stdout=subprocess.PIPE if capture else None,stderr=subprocess.PIPE if capture else None)
|
||||
changed={}; previous_image=None; replaced=False
|
||||
try:
|
||||
ids=cmd(compose+['ps','-q','netplan-v4'],capture=True).stdout.split()
|
||||
if len(ids)!=1: raise ValueError('Expected one running V4 container')
|
||||
previous_image=cmd(['docker','inspect','--format','{{.Image}}',ids[0]],capture=True).stdout.strip()
|
||||
result['previousImage']=previous_image
|
||||
for name, versions in manifest['files'].items():
|
||||
target=ROOT/name; content=(PACKAGE/'source'/name).read_bytes()
|
||||
before=target.read_bytes() if target.is_file() else None
|
||||
if digest(target)==versions['after']: continue
|
||||
if digest(target)!=versions['before']: raise ValueError('Concurrent source change')
|
||||
backup=folder/'source-before'/name; backup.parent.mkdir(parents=True,exist_ok=True)
|
||||
if before is not None: backup.write_bytes(before)
|
||||
atomic(target,content); changed[name]=(before,content)
|
||||
verify()
|
||||
cmd(compose+['build','netplan-v4'],timeout=900)
|
||||
cmd(compose+['run','--rm','--no-deps','--entrypoint','python','netplan-v4','/app/run_tests.py'],timeout=240)
|
||||
result['steps'].append('target_tests_passed'); verify()
|
||||
backup_database(ROOT/'data/netplan-v4.sqlite',folder/'before.sqlite')
|
||||
result['steps'].append('consistent_database_backup')
|
||||
rollback=folder/'rollback.yaml'
|
||||
rollback.write_text('services:\n netplan-v4:\n image: '+previous_image+'\n')
|
||||
replaced=True
|
||||
cmd(compose+['up','-d','--no-deps','--no-build','--wait','netplan-v4'])
|
||||
payload=json.dumps({'plant':plant,'dataset':json.loads((PACKAGE/'dataset.json').read_text())})
|
||||
response=cmd(compose+['exec','-T','netplan-v4','python','-c',BOOTSTRAP],capture=True,data=payload)
|
||||
result['application']=json.loads(response.stdout)
|
||||
result['status']='historical_timing_installed_no_actuation'
|
||||
result['steps'].append('versioned_dataset_registered_original_observations_retained')
|
||||
except Exception as exc:
|
||||
result['status']='needs_review';result['errorType']=type(exc).__name__
|
||||
conflicts=[]
|
||||
for name,(before,after) in changed.items():
|
||||
path=ROOT/name
|
||||
if digest(path)!=hashlib.sha256(after).hexdigest(): conflicts.append(name);continue
|
||||
if before is None:path.unlink()
|
||||
else:atomic(path,before)
|
||||
result['concurrentFilesNotOverwritten']=conflicts
|
||||
if replaced and previous_image:
|
||||
try:
|
||||
cmd(compose+['-f',str(folder/'rollback.yaml'),'up','-d','--no-deps','--no-build','--pull','never','--wait','netplan-v4'])
|
||||
result['rollback']='previous_image_restored_additive_dataset_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,gid=ROOT.stat().st_uid,ROOT.stat().st_gid
|
||||
for path in (folder.parent,folder,report):os.chown(path,uid,gid)
|
||||
report.chmod(0o640)
|
||||
print('HISTORY TIMING REPORT:',report)
|
||||
print('Only V4 updated. Original data, manager, polling, forecast selection and actuator permissions unchanged.')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--plant',required=True);parser.add_argument('--apply',action='store_true')
|
||||
args=parser.parse_args()
|
||||
try:run(args.plant,args.apply)
|
||||
except Exception as exc:raise SystemExit('Stopped: '+type(exc).__name__+'. See HISTORY TIMING REPORT; do not enable actuators.')
|
||||
Reference in New Issue
Block a user