139 lines
6.0 KiB
Python
139 lines
6.0 KiB
Python
#!/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()
|