178 lines
7.9 KiB
Python
178 lines
7.9 KiB
Python
#!/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
|