Files
Enelix-EMS/services/license/changes/20261006-forecast-integration/deploy.py
T
2026-10-06 17:40:45 +00:00

63 lines
2.7 KiB
Python

"""Deploy static assets only. Abort on drift; keep a private rollback snapshot."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import time
parser = argparse.ArgumentParser()
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
package = Path(__file__).resolve().parent
root = Path('/home/agent/services/license')
manifest = json.loads((package / 'manifest.json').read_text())
sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
for entry in manifest:
relative = Path(entry['path'])
if relative.is_absolute() or '..' in relative.parts or relative.parts[0] != 'public':
raise SystemExit('Unsafe manifest path')
target = root / relative
if target.is_symlink() or sha(target) != entry['before']:
raise SystemExit('Runtime drift: ' + str(relative))
if sha(package / relative) != entry['after']:
raise SystemExit('Release checksum mismatch: ' + str(relative))
if not args.apply:
print(json.dumps({'preflight': 'ok', 'files': len(manifest)}))
raise SystemExit(0)
backup = Path('/srv/agent/forecast-completion-20261006') / ('portal-backup-' + str(time.time_ns()))
backup.mkdir(mode=0o700)
for entry in manifest:
source = root / entry['path']
if source.exists():
target = backup / entry['path']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
shutil.copyfile(package / 'manifest.json', backup / 'manifest.json')
written = []
try:
# New imported dependencies first; the HTML entrypoint is switched last.
order = sorted(manifest, key=lambda e: (e['path'].endswith('index.html'), e['path'].endswith('app.js')))
for entry in order:
target = root / entry['path']
if sha(target) != entry['before']:
raise RuntimeError('Concurrent change: ' + entry['path'])
temp = target.with_name(target.name + '.forecast-new')
with temp.open('xb') as stream:
stream.write((package / entry['path']).read_bytes())
os.chmod(temp, target.stat().st_mode & 0o777 if target.exists() else 0o644)
os.replace(temp, target)
written.append(entry)
if any(sha(root / entry['path']) != entry['after'] for entry in manifest):
raise RuntimeError('Post-deployment checksum mismatch')
except Exception:
for entry in reversed(written):
target = root / entry['path']
if sha(target) != entry['after']:
continue
if entry['before'] is not None:
shutil.copy2(backup / entry['path'], target)
raise
print(json.dumps({'deployed': True, 'files': len(written), 'backup': str(backup)}))