143 lines
6.4 KiB
Python
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/'}))
|