Files
werkjournal/scripts/deploy-guest-preview-remote.py
2026-09-08 22:49:11 +02:00

143 lines
6.4 KiB
Python

#!/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/'}))