104 lines
5.4 KiB
Python
Executable File
104 lines
5.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Start an isolated JVM process and verify HTTP readiness and PWA resources."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('jar', type=Path)
|
|
parser.add_argument('--java', type=Path, default=Path(os.environ.get('JAVA_HOME', '/usr')) / 'bin/java')
|
|
parser.add_argument('--timeout', type=int, default=90)
|
|
args = parser.parse_args()
|
|
jar = args.jar.resolve(strict=True)
|
|
java = args.java.resolve(strict=True)
|
|
with socket.socket() as sock:
|
|
sock.bind(('127.0.0.1', 0))
|
|
port = sock.getsockname()[1]
|
|
base = f'http://127.0.0.1:{port}'
|
|
# Never inherit application secrets, OIDC settings or a developer's JAVA_HOME.
|
|
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8'}
|
|
with tempfile.TemporaryDirectory(prefix='werkjournal-smoke-') as directory:
|
|
env['HOME'] = directory
|
|
mail_marker = Path(directory) / 'mail-invoked'
|
|
sendmail = Path(directory) / 'sendmail-trap'
|
|
sendmail.write_text('#!/bin/sh\n: > mail-invoked\nexit 1\n')
|
|
sendmail.chmod(0o700)
|
|
env['WERKJOURNAL_SENDMAIL'] = str(sendmail)
|
|
env['WERKJOURNAL_PUSH_ENABLED'] = 'false'
|
|
logfile = Path(directory) / 'server.log'
|
|
with logfile.open('wb') as log:
|
|
process = subprocess.Popen(
|
|
[str(java), '-Xmx256m', '-XX:MaxMetaspaceSize=192m', '-XX:ActiveProcessorCount=2', '-Djava.awt.headless=true', '-jar', str(jar), f'--server.port={port}', '--server.address=127.0.0.1'],
|
|
cwd=directory, env=env, stdout=log, stderr=subprocess.STDOUT,
|
|
)
|
|
try:
|
|
deadline = time.monotonic() + args.timeout
|
|
while True:
|
|
if process.poll() is not None:
|
|
raise RuntimeError(f'JVM process exited with {process.returncode}')
|
|
try:
|
|
with urllib.request.urlopen(base + '/api/v1/info', timeout=2) as response:
|
|
info = json.load(response)
|
|
if info.get('application') != 'werkjournal' or not info.get('version'):
|
|
raise RuntimeError(f'Unexpected readiness response: {info}')
|
|
break
|
|
except (urllib.error.URLError, TimeoutError):
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError('JVM readiness timed out')
|
|
time.sleep(0.25)
|
|
for resource, expected in [('/', b'<html'), ('/offline.html', b'Werkjournal'), ('/sw.js', b'icons/journal.png')]:
|
|
with urllib.request.urlopen(base + resource, timeout=5) as response:
|
|
if expected not in response.read():
|
|
raise RuntimeError(f'Missing content in {resource}')
|
|
with urllib.request.urlopen(base + '/manifest.webmanifest', timeout=5) as response:
|
|
manifest = json.load(response)
|
|
if manifest.get('name') != 'Werkjournal' or not manifest.get('icons'):
|
|
raise RuntimeError('Incomplete PWA manifest')
|
|
for icon in manifest['icons']:
|
|
icon_url = urllib.parse.urljoin(base + '/', icon['src'])
|
|
if urllib.parse.urlparse(icon_url).netloc != urllib.parse.urlparse(base).netloc:
|
|
raise RuntimeError('Unexpected external PWA icon')
|
|
with urllib.request.urlopen(icon_url, timeout=5) as response:
|
|
if not response.headers.get('Content-Type', '').startswith('image/') or not response.read():
|
|
raise RuntimeError('PWA icon is missing or is not an image')
|
|
with urllib.request.urlopen(base + '/sw.js', timeout=5) as response:
|
|
if 'javascript' not in response.headers.get('Content-Type', '') or not response.read():
|
|
raise RuntimeError('Service worker is missing or is not JavaScript')
|
|
if process.poll() is not None:
|
|
raise RuntimeError('Process stopped during smoke checks')
|
|
except Exception:
|
|
print(logfile.read_text(errors='replace'))
|
|
raise
|
|
finally:
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
# Exercise the actual packaged offline entry point against the migrated file DB.
|
|
# Capture the secret; never include it in diagnostics or smoke output.
|
|
previous = None
|
|
for _ in range(2):
|
|
recovery = subprocess.run([str(java), '-Xmx128m', '-jar', str(jar), '--prepare-admin-recovery'],
|
|
cwd=directory, env=env, stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL, text=True, timeout=30)
|
|
if recovery.returncode != 0 or re.fullmatch(r'[A-Za-z0-9_-]{43}', recovery.stdout) is None:
|
|
raise RuntimeError('Packaged offline administrator recovery failed')
|
|
if recovery.stdout == previous:
|
|
raise RuntimeError('Administrator recovery did not rotate the code')
|
|
previous = recovery.stdout
|
|
if mail_marker.exists():
|
|
raise RuntimeError('Smoke attempted mail delivery')
|
|
print(json.dumps({'mailInvocations': 0, 'pushDisabled': True, 'result': 'PASS', 'info': info, 'jar': str(jar), 'adminRecovery': 'PASS'}))
|