#!/usr/bin/env python3 """One-time transition from the known stateless prototype to the first guest UI. Intentionally refuses any pre-existing database or different deployment. General upgrades and database backups are implemented by the subsequent operations node. """ import fcntl import hashlib import json import os from pathlib import Path import re import shutil import subprocess import sys import tarfile import tempfile import time import urllib.error import urllib.request EXPECTED = 'f7464cf232c347d59bb0025ce5eec450d239503c' OLD_SHA = '6aaa1639aca222b759f1755033cc4916beab2f0db7ddd8093dd153c14f40e73d' home = Path.home() app = home / 'opt/werkjournal' site = home / 'doms/werkjournal.javagil.de/htdocs-ssl' service = 'werkjournal-backend.service' def run(*args): return subprocess.run(args, check=True, capture_output=True, text=True).stdout def atomic(path, text): candidate = path.with_name(path.name + '.new') candidate.write_text(text) candidate.chmod(0o600) candidate.replace(path) def link(path, target): candidate = path.with_name(path.name + '.next') if candidate.exists() or candidate.is_symlink(): raise RuntimeError('Unresolved deployment link exists') candidate.symlink_to(target) candidate.replace(path) def ready(): deadline = time.monotonic() + 90 while time.monotonic() < deadline: try: with urllib.request.urlopen('http://127.0.0.1:18090/api/v1/info', timeout=3) as response: if json.load(response).get('application') == 'werkjournal': return except (OSError, ValueError): pass time.sleep(2) raise RuntimeError('Application did not become ready') if not app.is_dir() or not site.is_dir(): raise SystemExit('Expected Hostsharing deployment is missing') with (app / '.deploy.lock').open('a') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) old = app / 'releases' / EXPECTED if ((app / 'current').resolve() != old or (site / 'maintenance.flag').exists() or list(app.rglob('*.mv.db')) or (app / 'data').exists() or (home / '.config/werkjournal/environment').exists()): raise SystemExit('Only the known stateless prototype without database/configuration may be upgraded here') prior = (app / 'deployed.json').read_text() if json.loads(prior).get('commit') != EXPECTED: raise SystemExit('Unexpected deployment manifest') with (old / 'werkjournal.jar').open('rb') as source: if hashlib.file_digest(source, 'sha256').hexdigest() != OLD_SHA: raise SystemExit('Unexpected previous application') if run('systemctl', '--user', 'is-active', service).strip() != 'active': raise SystemExit('Previous application is not active') stage = Path(tempfile.mkdtemp(prefix='guest-candidate-', dir=app)) try: seen = set() with tarfile.open(fileobj=sys.stdin.buffer, mode='r|') as archive: for entry in archive: if (entry.name not in {'manifest.json', 'werkjournal.jar'} or entry.name in seen or not entry.isfile() or entry.size > 300 * 1024 * 1024): raise ValueError('Invalid artifact archive') seen.add(entry.name) with archive.extractfile(entry) as source, (stage / entry.name).open('wb') as destination: shutil.copyfileobj(source, destination) if seen != {'manifest.json', 'werkjournal.jar'}: raise ValueError('Incomplete artifact') manifest = json.loads((stage / 'manifest.json').read_text()) with (stage / 'werkjournal.jar').open('rb') as source: checksum = hashlib.file_digest(source, 'sha256').hexdigest() if (manifest.get('schemaVersion') != 1 or manifest.get('runtime') != 'java-25' or not re.fullmatch('[0-9a-f]{40}', manifest.get('commit', '')) or manifest.get('sha256') != checksum or manifest.get('files') != {'werkjournal.jar': checksum}): raise ValueError('Unverified artifact') release = app / 'releases' / manifest['commit'] if release.exists(): raise ValueError('Candidate release already exists') stage.rename(release) finally: if stage.exists(): shutil.rmtree(stage) atomic(site / 'maintenance.flag', 'Publishing first guest preview\n') try: try: urllib.request.urlopen('https://werkjournal.javagil.de/', timeout=15) raise RuntimeError('Maintenance did not return HTTP 503') except urllib.error.HTTPError as error: if error.code != 503 or 'Wartungsmodus' not in error.read().decode(): raise run('systemctl', '--user', 'stop', service) # Recheck after closing the old process before switching applications. if list(app.rglob('*.mv.db')): raise RuntimeError('Unexpected data: refusing the stateless transition') link(app / 'current', release.relative_to(app)) run('systemctl', '--user', 'start', service) ready() with urllib.request.urlopen('http://127.0.0.1:18090/?v-r=init&location=catalog&query=', timeout=15) as response: if 'application/json' not in response.headers.get('Content-Type', '') or not isinstance(json.load(response).get('appConfig'), dict): raise RuntimeError('Guest Flow initialization failed') if not list((app / 'data').glob('*.mv.db')): raise RuntimeError('Persistent guest database was not initialized') link(app / 'previous', old.relative_to(app)) atomic(app / 'deployed.json', json.dumps(manifest, indent=2) + '\n') except BaseException: # No public access has been opened. Retain failed candidate data for inspection. run('systemctl', '--user', 'stop', service) if (app / 'current').resolve() == release and (app / 'data').exists(): (app / 'data').rename(release / 'failed-initial-data') link(app / 'current', old.relative_to(app)) atomic(app / 'deployed.json', prior) run('systemctl', '--user', 'start', service) ready() (site / 'maintenance.flag').unlink() raise # Publication is the final step. After this point preserve any new guest data. (site / 'maintenance.flag').unlink() print(json.dumps({'result': 'PASS', 'commit': manifest['commit'], 'url': 'https://werkjournal.javagil.de/'}))