Files
werkjournal/scripts/deploy-prototype-remote.py
2026-09-08 21:41:51 +02:00

178 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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', '<!doctype html><html lang="de"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Werkjournal — Wartung</title><body><h1>Werkjournal</h1><p>Wartungsmodus bitte in wenigen Minuten erneut versuchen.</p><p lang="en">Maintenance — please try again in a few minutes.</p></body></html>')
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]
<Files "maintenance.html">
Header always set Cache-Control "no-store"
Header always set Retry-After "60"
</Files>
''')
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