feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s
Tests / test (push) Failing after 49s
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user