feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s

This commit is contained in:
dh
2026-10-06 14:35:17 +00:00
parent faa905ab09
commit 5687596799
14 changed files with 9125 additions and 0 deletions
@@ -0,0 +1,138 @@
#!/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()