#!/usr/bin/env python3 """Apply the reviewed portal patch as an authorized server administrator.""" import hashlib import json import os from pathlib import Path import shutil import subprocess import sys import tempfile import time from datetime import datetime, timezone from urllib.request import urlopen APP = Path('/home/agent/services/license') PACKAGE = Path(__file__).resolve().parent CONTAINER = 'enelix_license_portal' def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None def docker(*args): return subprocess.run(['docker', *args], check=True, capture_output=True, text=True).stdout def write_in_place(path, content): # Keep existing file bind mounts attached to the same inode. with path.open('wb') as target: target.write(content) target.flush() os.fsync(target.fileno()) def verify_runtime(): with urlopen('https://license.enelix.ch/healthz', timeout=5) as response: if response.status != 200: raise RuntimeError('Portal health check failed') with urlopen('https://license.enelix.ch/api/wizard-catalog', timeout=5) as response: catalog = json.load(response) if 'heat_pump' not in catalog.get('consumerFields', {}): raise RuntimeError('Heat-pump wizard is not served') with urlopen('https://license.enelix.ch/', timeout=5) as response: html = response.read().decode() if 'class="header-links"' not in html or 'data-device-quantity="heat_pump"' not in html: raise RuntimeError('New portal frontend is not served') def write_catalog(path, content): metadata = path.stat() descriptor, temporary = tempfile.mkstemp(prefix='.catalog-release-', dir=path.parent) try: with os.fdopen(descriptor, 'wb') as target: target.write(content) target.flush() os.fsync(target.fileno()) os.chmod(temporary, metadata.st_mode & 0o777) os.chown(temporary, metadata.st_uid, metadata.st_gid) os.replace(temporary, path) finally: if os.path.exists(temporary): os.unlink(temporary) def main(): if os.geteuid() != 0: raise RuntimeError('Run this reviewed deployment as an authorized administrator (root).') manifest = json.loads((PACKAGE / 'manifest.json').read_text()) for name, hashes in manifest['files'].items(): if Path(name).is_absolute() or '..' in Path(name).parts: raise RuntimeError('Invalid file path') if digest(APP / name) != hashes['before']: raise RuntimeError('Live file changed; stop for conflict review: ' + name) if digest(PACKAGE / 'files' / name) != hashes['after']: raise RuntimeError('Package checksum mismatch: ' + name) state = json.loads(docker('inspect', '--format', '{{json .State}}', CONTAINER)) if not state.get('Running'): raise RuntimeError('Portal is not running; resolve its state before deployment.') mounts = json.loads(docker('inspect', '--format', '{{json .Mounts}}', CONTAINER)) for name in ['server.mjs', 'stripe.mjs', 'portal-domain.mjs', 'symcon-package.mjs', 'public', 'data']: if not any(m['Source'] == str(APP / name) and m['Destination'] == '/app/' + name for m in mounts): raise RuntimeError('Unexpected container mount: ' + name) catalog_path = APP / 'data/catalog.json' catalog_bytes = catalog_path.read_bytes() catalog = json.loads(catalog_bytes) if catalog.get('items', {}).get('heat_pump', {}).get('sku') != 'ENX-HEAT-PUMP': raise RuntimeError('Expected heat-pump catalog entry is missing.') backup = APP / 'change-backups' / ('heat-pump-header-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')) backup.mkdir(mode=0o700, parents=True, exist_ok=False) for name in manifest['files']: destination = backup / name destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(APP / name, destination) (backup / 'catalog.json').write_bytes(catalog_bytes) (backup / 'catalog.json').chmod(0o600) # Refuse concurrent source edits before stopping the portal. for name, hashes in manifest['files'].items(): if digest(APP / name) != hashes['before']: raise RuntimeError('Concurrent source change: ' + name) if catalog_path.read_bytes() != catalog_bytes: raise RuntimeError('Catalog changed during preflight; retry after review.') written_catalog = None docker('stop', '--time', '20', CONTAINER) try: if catalog_path.read_bytes() != catalog_bytes: raise RuntimeError('Catalog changed during stop; no catalog migration applied.') for name in manifest['files']: write_in_place(APP / name, (PACKAGE / 'files' / name).read_bytes()) catalog['items']['heat_pump']['available'] = True catalog['updatedAt'] = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') written_catalog = (json.dumps(catalog, ensure_ascii=False, indent=2) + '\n').encode() write_catalog(catalog_path, written_catalog) docker('start', CONTAINER) for attempt in range(30): try: verify_runtime() print('Portal deployed and verified. Backup: ' + str(backup)) return except Exception: if attempt == 29: raise time.sleep(1) except Exception: docker('stop', '--time', '20', CONTAINER) for name in manifest['files']: write_in_place(APP / name, (backup / name).read_bytes()) # Do not overwrite a concurrent backoffice catalog update. current_catalog = catalog_path.read_bytes() if written_catalog is not None and current_catalog == written_catalog: write_catalog(catalog_path, catalog_bytes) docker('start', CONTAINER) print('Deployment failed; original application files restored. Backup: ' + str(backup), file=sys.stderr) raise if __name__ == '__main__': main()