#!/usr/bin/env python3 """Install the initial, stateless prototype. Refuse upgrades and any existing data.""" 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.request import urllib.error home = Path.home() app = home / 'opt/werkjournal' site = home / 'doms/werkjournal.javagil.de/htdocs-ssl' http_site = home / 'doms/werkjournal.javagil.de/htdocs' jdk = home / 'opt/jdk25' unit = home / '.config/systemd/user/werkjournal-backend.service' service = 'werkjournal-backend.service' def run(*args): return subprocess.run(args, check=True, text=True, capture_output=True).stdout def atomic(path, content, mode=0o644): temporary = path.with_name(path.name + '.new') temporary.write_text(content) temporary.chmod(mode) temporary.replace(path) def wait_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: info = json.load(response) if info.get('application') == 'werkjournal' and info.get('version') == manifest['version']: return except (OSError, ValueError): pass time.sleep(2) raise RuntimeError('Prototype readiness check failed') if not site.is_dir() or not http_site.is_dir() or not (jdk / 'bin/java').is_file(): raise SystemExit('Expected Hostsharing domain directories and managed JDK must exist') app.mkdir(parents=True, exist_ok=True, mode=0o700) with (app / '.deploy.lock').open('a') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) if (app / 'current').exists() or (app / 'current').is_symlink() or unit.exists() or list(app.rglob('*.mv.db')): raise SystemExit('Initial deployment only: existing service, release or data requires the full upgrade procedure') # Refuse unrelated listeners before touching the website. import socket with socket.socket() as probe: probe.bind(('127.0.0.1', 18090)) stage = Path(tempfile.mkdtemp(prefix='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(): raise ValueError('Unexpected archive member') if entry.size > 300 * 1024 * 1024: raise ValueError('Artifact too large') 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(r'[0-9a-f]{40}', manifest.get('commit', '')) or checksum != manifest.get('sha256') or manifest.get('files') != {'werkjournal.jar': checksum}): raise ValueError('Unverified artifact') release = app / 'releases' / manifest['commit'] release.parent.mkdir(exist_ok=True) if release.exists(): raise ValueError('Release path already exists') stage.rename(release) finally: if stage.exists(): shutil.rmtree(stage) backup = app / 'initial-site-backup' backup.mkdir(mode=0o700) for root, label in [(site, 'https'), (http_site, 'http')]: destination = backup / label destination.mkdir() for name in ['.htaccess', 'index.html']: path = root / name if path.exists(): shutil.copy2(path, destination / name) atomic(site / 'maintenance.html', 'Werkjournal — Wartung

Werkjournal

Wartungsmodus – bitte in wenigen Minuten erneut versuchen.

Maintenance — please try again in a few minutes.

') atomic(site / 'maintenance.flag', 'Initial deployment\n') atomic(site / '.htaccess', '''DirectoryIndex disabled RewriteEngine On ErrorDocument 503 /maintenance.html RewriteRule ^maintenance\\.html$ - [L] RewriteRule ^\\.well-known/acme-challenge/ - [L] RewriteCond %{DOCUMENT_ROOT}/maintenance.flag -f RewriteRule ^ - [R=503,L] RequestHeader set X-Forwarded-Proto "https" RequestHeader set X-Forwarded-Port "443" RequestHeader set X-Forwarded-Host "werkjournal.javagil.de" RewriteRule ^(.*)$ http://127.0.0.1:18090/$1?%{QUERY_STRING} [P,L] Header always set Cache-Control "no-store" Header always set Retry-After "60" ''') atomic(http_site / '.htaccess', '''RewriteEngine On RewriteRule ^\\.well-known/acme-challenge/ - [L] RewriteRule ^ https://werkjournal.javagil.de%{REQUEST_URI} [R=302,L] ''') for root in [site, http_site]: (root / 'index.html').unlink(missing_ok=True) unit.parent.mkdir(parents=True, exist_ok=True) # Optional environment file remains outside releases and is never overwritten. atomic(unit, f'''[Unit] Description=Werkjournal backend After=network.target [Service] Type=simple WorkingDirectory={app} ExecStart={jdk}/bin/java -Xms32m -Xmx384m -XX:MaxMetaspaceSize=192m -XX:ActiveProcessorCount=2 -Djava.awt.headless=true -jar {app}/current/werkjournal.jar Environment=BACKEND_PORT=18090 Environment=SERVER_ADDRESS=127.0.0.1 Environment=SERVER_FORWARD_HEADERS_STRATEGY=native Environment=SERVER_TOMCAT_REMOTEIP_HOST_HEADER=x-forwarded-host EnvironmentFile=-%h/.config/werkjournal/environment Restart=on-failure RestartSec=5 TimeoutStopSec=60 UMask=0077 [Install] WantedBy=default.target ''', 0o600) (app / 'current').symlink_to(release.relative_to(app)) try: run('systemctl', '--user', 'daemon-reload') run('systemctl', '--user', 'enable', '--now', service) wait_ready() # Verify public maintenance before opening access. try: urllib.request.urlopen('https://werkjournal.javagil.de/', timeout=15) raise RuntimeError('Public maintenance did not return HTTP 503') except urllib.error.HTTPError as error: if error.code != 503 or 'Wartungsmodus' not in error.read().decode(): raise (site / 'maintenance.flag').unlink() with urllib.request.urlopen('https://werkjournal.javagil.de/api/v1/info', timeout=15) as response: public = json.load(response) if public != {'application': 'werkjournal', 'version': manifest['version']}: raise RuntimeError('Public proxy readiness failed') with urllib.request.urlopen('https://werkjournal.javagil.de/?v-r=init&location=&query=', timeout=15) as response: if 'application/json' not in response.headers.get('Content-Type', ''): raise RuntimeError('Flow initialization was intercepted by the web server') if not isinstance(json.load(response).get('appConfig'), dict): raise RuntimeError('Flow initialization did not contain appConfig') atomic(app / 'deployed.json', json.dumps(manifest, indent=2) + '\n', 0o600) print(json.dumps({'result': 'PASS', 'commit': manifest['commit'], 'sha256': checksum, 'url': 'https://werkjournal.javagil.de', 'service': service})) except BaseException: atomic(site / 'maintenance.flag', 'Initial deployment failed\n') subprocess.run(['systemctl', '--user', 'disable', '--now', service], check=False, capture_output=True) raise