Files
werkjournal/tools/test-migration-rollback
2026-10-13 06:33:51 +02:00

386 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
"""WJ-021: real packaged application and H2, production deployment file operations."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
import urllib.error
import urllib.request
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('deployment', ROOT / 'scripts/deploy-update-remote.py')
deployment = importlib.util.module_from_spec(spec)
spec.loader.exec_module(deployment)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('jar', type=Path)
parser.add_argument('--report', type=Path, required=True)
parser.add_argument('--failures', type=int, choices=[1, 2], default=1)
parser.add_argument('--fail-rollback', action='store_true')
parser.add_argument('--failure-worker', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('--upload-only', action='store_true')
args = parser.parse_args()
jar = args.jar.resolve(strict=True)
java = (Path(os.environ['JAVA_HOME']) / 'bin/java').resolve(strict=True)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.unlink(missing_ok=True)
if args.fail_rollback and args.failures != 1:
parser.error('--fail-rollback requires one candidate')
if args.upload_only and (args.fail_rollback or args.failures != 1):
parser.error('--upload-only requires one candidate and no rollback failure')
if args.fail_rollback and not args.failure_worker:
output = args.report.with_name(args.report.stem + '-failure.log')
with output.open('wb') as log:
worker = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), str(jar),
'--report', str(args.report), '--fail-rollback', '--failure-worker'],
stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
try:
# Above the readiness waits this worker performs, or raising those would only
# move the failure here, where it reads as a hung fixture instead of a slow host.
worker.wait(timeout=600)
finally:
if worker.poll() is None:
os.killpg(worker.pid, signal.SIGTERM)
try:
worker.wait(timeout=5)
finally:
# The worker and every JVM share this private process group.
try:
os.killpg(worker.pid, signal.SIGKILL)
except ProcessLookupError:
pass
worker.wait()
assert worker.returncode != 0, 'Failed rollback worker incorrectly returned success'
failure_log = output.read_text()
assert 'Expected candidate migration failure' in failure_log
assert 'Expected rollback readiness failure' in failure_log
report = json.loads(args.report.read_text())
assert report['result'] == 'EXPECTED_FAILURE'
assert report['maintenanceRetained'] and report['journalRetained'] and report['rollbackStopped']
report.update(result='PASS', failureExitCode=worker.returncode, bothFailuresLogged=True)
args.report.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report))
return
logs = args.report.parent / (args.report.stem + '-logs')
logs.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='werkjournal-rollback-') as temporary:
home = Path(temporary)
app = home / 'opt/werkjournal'
app.mkdir(parents=True)
with zipfile.ZipFile(jar) as archive:
h2_member, = [name for name in archive.namelist() if name.startswith('BOOT-INF/lib/h2-')]
h2 = home / 'h2.jar'
h2.write_bytes(archive.read(h2_member))
properties = archive.read('META-INF/build-info.properties').decode()
version = next(line.split('=', 1)[1] for line in properties.splitlines() if line.startswith('build.version='))
old = 'a' * 40
candidates = [letter * 40 for letter in 'bc'[:args.failures]]
for commit in [old, *candidates]:
release = app / 'releases' / commit
release.mkdir(parents=True)
target = release / 'werkjournal.jar'
if commit == old:
shutil.copyfile(jar, target)
else:
with zipfile.ZipFile(jar) as source, zipfile.ZipFile(target, 'w') as dest:
for entry in source.infolist():
data = source.read(entry.filename)
if entry.filename == 'BOOT-INF/classes/db/changelog.sql':
data += b'\n--changeset rollback-test:partial-failure dbms:h2 runInTransaction:false\nCREATE TABLE rollback_probe (id INTEGER PRIMARY KEY);\nINSERT INTO rollback_probe VALUES (1);\nSELECT * FROM intentionally_missing_rollback_table;\n'
dest.writestr(entry, data)
digest = deployment.checksum(target)
manifest = dict(schemaVersion=1, runtime='java-25', version=version, commit=commit,
sha256=digest, files={'werkjournal.jar': digest}, ancestors=[] if commit == old else [old])
(release / 'manifest.json').write_text(json.dumps(manifest))
(app / 'current').symlink_to(Path('releases') / old)
shutil.copyfile(app / 'current/manifest.json', app / 'deployed.json')
packages = {}
for candidate in candidates:
package = home / f'{candidate}.tar'
with tarfile.open(package, 'w') as archive:
for name in ['werkjournal.jar', 'manifest.json']:
archive.add(app / 'releases' / candidate / name, arcname=name)
packages[candidate] = package
# stage() must actually unpack and validate the candidate.
shutil.rmtree(app / 'releases' / candidate)
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'HOME': str(home),
'WERKJOURNAL_GUEST_MODE': 'true', 'WERKJOURNAL_PUSH_ENABLED': 'false'}
trap = home / 'sendmail'
trap.write_text('#!/bin/sh\ntouch "' + str(home / 'unexpected-mail') + '"\nexit 1\n')
trap.chmod(0o700)
env['WERKJOURNAL_SENDMAIL'] = str(trap)
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
base = f'http://127.0.0.1:{port}'
def sql(statement):
result = subprocess.run([str(java), '-cp', str(h2), 'org.h2.tools.Shell', '-url',
'jdbc:h2:file:' + str(app / 'data/werkjournal'), '-user', 'sa', '-password', '',
'-sql', statement], env=env, capture_output=True, text=True, timeout=60, check=True)
if 'Error:' in result.stdout:
raise AssertionError(result.stdout)
return result.stdout
class LocalDeployment(deployment.Deployment):
process = None
log = None
starts = 0
migration_failed = False
matched_backup = False
ready_with_maintenance = False
blocked_port = None
def preflight(self, check_data=True):
# systemd configuration is covered by operations tests; this fixture owns its process.
assert self.home == home and self.app == app
assert (self.app / 'data/werkjournal.mv.db').is_file()
def stop(self):
if self.process is not None:
if self.process.poll() is None:
self.process.terminate()
try:
# Generous, because this runs beside the browser suites on a shared
# host: the same release closed its database in 0.2 s when it had the
# machine to itself. Only a starved host waits anywhere near this.
self.process.wait(timeout=60)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=20)
raise AssertionError('Application failed to close its database gracefully')
assert self.process.poll() is not None
self.log.close()
self.process = None
def start(self):
if self.migration_failed:
assert (self.app / 'current').resolve().name == old
state = json.loads(self.pending.read_text())
inventory = json.loads((self.app / 'rollbacks' / state['backup'] / 'inventory.json').read_text())
assert all(deployment.checksum(self.app / 'data' / name) == digest for name, digest in inventory.items())
self.matched_backup = True
if args.fail_rollback:
self.blocked_port = socket.socket()
self.blocked_port.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.blocked_port.bind(('127.0.0.1', port))
self.blocked_port.listen()
self.starts += 1
self.logpath = logs / f'start-{self.starts}.log'
self.log = self.logpath.open('wb')
self.process = subprocess.Popen([str(java), '-Xmx256m', '-XX:ActiveProcessorCount=2',
'-jar', str(self.app / 'current/werkjournal.jar'), '--server.address=127.0.0.1',
f'--server.port={port}'], cwd=self.app, env=env, stdout=self.log, stderr=subprocess.STDOUT)
def ready(self, expected):
# Three minutes for a start that takes one to three seconds on an idle machine.
# On 2026-10-12 this host needed 17.1 s for one start and had not finished the
# migration of the next after 25 s, with Firefox and its own applications on the
# same three threads; the branch build failed with nothing wrong in the release.
# The proof is unchanged: the application must become ready, and this only grants
# a starved host the time to get there.
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
if self.process.poll() is not None:
if args.fail_rollback and self.migration_failed and expected['commit'] == old:
assert self.process.returncode != 0
assert 'already in use' in self.logpath.read_text().lower()
raise RuntimeError('Expected rollback readiness failure')
assert expected['commit'] in candidates and self.process.returncode != 0
assert 'intentionally_missing_rollback_table' in self.logpath.read_text()
assert 'PARTIAL_MIGRATION_CONFIRMED' in sql("SELECT 'PARTIAL_MIGRATION_CONFIRMED' FROM rollback_probe WHERE id=1")
self.migration_failed = True
raise RuntimeError('Expected candidate migration failure')
try:
with urllib.request.urlopen(base + '/api/v1/info', timeout=2) as response:
assert json.load(response) == {'application': 'werkjournal', 'version': version}
with urllib.request.urlopen(base + '/?v-r=init&location=&query=', timeout=3) as response:
assert isinstance(json.load(response).get('appConfig'), dict)
if self.migration_failed:
assert self.flag.exists()
try:
urllib.request.urlopen(self.url, timeout=2)
raise AssertionError('Public access opened before readiness')
except urllib.error.HTTPError as error:
assert error.code == 503
self.ready_with_maintenance = True
return
except (urllib.error.URLError, TimeoutError):
time.sleep(.2)
raise AssertionError('Application readiness timed out')
runner = LocalDeployment(home)
runner.site.mkdir(parents=True)
class Gateway(BaseHTTPRequestHandler):
def do_GET(self):
body = runner.flag.read_bytes() if runner.flag.exists() else b'public access available'
self.send_response(503 if runner.flag.exists() else 200)
self.end_headers()
self.wfile.write(body)
def log_message(self, *unused):
pass
gateway = ThreadingHTTPServer(('127.0.0.1', 0), Gateway)
threading.Thread(target=gateway.serve_forever, daemon=True).start()
runner.url = f'http://127.0.0.1:{gateway.server_port}'
try:
runner.start()
runner.ready({'commit': old})
runner.stop()
ids = [f'10000000-0000-0000-0000-{n:012d}' for n in range(1, 5)]
user, customer, project, entry = ids
sql(f"INSERT INTO app_user(id,display_name) VALUES('{user}','Rollback fixture');"
f"INSERT INTO customer(id,owner_id,name) VALUES('{customer}','{user}','Rollback customer');"
f"INSERT INTO work_project(id,owner_id,customer_id,name) VALUES('{project}','{user}','{customer}','Rollback project');"
f"INSERT INTO work_entry(id,owner_id,customer_id,project_id,work_date,hours,description) VALUES('{entry}','{user}','{customer}','{project}',DATE '2026-09-01',3,'Preserve this booking');")
runner.start()
runner.ready({'commit': old})
if args.upload_only:
client = home / 'client'
(client / 'tools').mkdir(parents=True)
(client / 'scripts').mkdir()
for name in ['tools/remote', 'scripts/lib-env.sh', 'scripts/deploy-prototype.py',
'scripts/deploy-update-remote.py']:
shutil.copy2(ROOT / name, client / name)
artifact = client / 'artifact'
artifact.mkdir()
with tarfile.open(packages[candidates[0]]) as archive:
for name in ['manifest.json', 'werkjournal.jar']:
with archive.extractfile(name) as source, (artifact / name).open('wb') as target:
shutil.copyfileobj(source, target)
transport = home / 'transport'
transport.mkdir()
captured = transport / 'upload.tar'
ssh = transport / 'ssh'
ssh.write_text("#!/usr/bin/env python3\nimport pathlib, shlex, sys\n"
"assert sys.argv[1:4] == ['-o', 'BatchMode=yes', 'fixture@invalid']\n"
"assert len(sys.argv) == 5 and shlex.split(sys.argv[4])[-1] == '--upload'\n"
"target = pathlib.Path(__file__).with_name('upload.tar')\n"
"with target.open('xb') as output:\n"
" import shutil\n shutil.copyfileobj(sys.stdin.buffer, output)\n")
ssh.chmod(0o700)
client_env = dict(env, PATH=str(transport) + ':/usr/bin:/bin', BACKEND_SSH='fixture@invalid')
active_pid = runner.process.pid
selected = (app / 'current').readlink()
deployed_bytes = (app / 'deployed.json').read_bytes()
subprocess.run([str(client / 'tools/remote'), 'backend', 'upload', '-y',
'--artifact', str(artifact)], env=client_env, cwd=client,
check=True, capture_output=True, timeout=30)
# Execute the real server upload path on the exact bytes sent by remote.
with captured.open('rb') as stream:
uploaded = runner.upload(stream)
assert uploaded['commit'] == candidates[0]
assert deployment.manifest(app / 'releases' / candidates[0]) == json.loads((artifact / 'manifest.json').read_text())
assert runner.process.pid == active_pid and runner.process.poll() is None
assert (app / 'current').readlink() == selected
assert (app / 'deployed.json').read_bytes() == deployed_bytes
assert not runner.flag.exists() and not runner.pending.exists()
# Use the actual restart control with only the process adapter replaced.
controls = dict(vars(deployment), __name__='service_control_fixture')
exec(compile((ROOT / 'scripts/service-control-remote.py').read_text(),
'service-control-remote.py', 'exec'), controls)
assert controls['control'](runner, 'restart') == 0
assert runner.process.pid != active_pid and runner.process.poll() is None
assert (app / 'current').readlink() == selected
assert (app / 'deployed.json').read_bytes() == deployed_bytes
assert not runner.flag.exists() and not runner.pending.exists()
runner.stop()
assert 'BOOKING_PRESERVED' in sql(f"SELECT 'BOOKING_PRESERVED' FROM work_entry WHERE id='{entry}' AND hours=3")
assert 'CANDIDATE_NOT_RUN' in sql("SELECT 'CANDIDATE_NOT_RUN' WHERE NOT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='ROLLBACK_PROBE')")
assert not (home / 'unexpected-mail').exists()
report = dict(result='PASS', uploadedCommit=candidates[0], activeCommit=old,
uploadKeptProcess=True, restartKeptRelease=True, bookingPreserved=True,
candidateMigrationNotRun=True, mailInvocations=0, pushDisabled=True)
args.report.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report))
return
rounds = []
failed_rollback = None
for index, candidate in enumerate(candidates):
runner.migration_failed = runner.matched_backup = runner.ready_with_maintenance = False
with packages[candidate].open('rb') as archive:
try:
runner.deploy(archive)
raise AssertionError('Failed migration was reported as successful')
except RuntimeError as error:
if args.fail_rollback:
assert str(error) == 'Expected rollback readiness failure'
assert str(error.__context__) == 'Expected candidate migration failure'
failed_rollback = error
else:
assert str(error) == 'Expected candidate migration failure'
if args.fail_rollback:
assert runner.migration_failed and runner.matched_backup
assert runner.process is None, 'Failed rollback left a running process'
assert runner.flag.exists() and runner.pending.exists()
state = json.loads(runner.pending.read_text())
assert state['old']['commit'] == old and state['new']['commit'] == candidate
assert state['phase'] == 'switched'
assert (app / 'current').resolve().name == old
assert 'BOOKING_PRESERVED' in sql(f"SELECT 'BOOKING_PRESERVED' FROM work_entry WHERE id='{entry}' AND hours=3")
try:
urllib.request.urlopen(runner.url, timeout=2)
raise AssertionError('Failed rollback exposed the application')
except urllib.error.HTTPError as error:
assert error.code == 503 and 'Wartungsmodus' in error.read().decode()
break
assert runner.migration_failed and runner.matched_backup and runner.ready_with_maintenance
assert not runner.flag.exists() and not runner.pending.exists()
assert json.loads((app / 'deployed.json').read_text())['commit'] == old
assert (app / 'current').resolve().name == old
with urllib.request.urlopen(runner.url, timeout=2) as response:
assert response.status == 200
runner.stop()
assert 'BOOKING_PRESERVED' in sql(f"SELECT 'BOOKING_PRESERVED' FROM work_entry WHERE id='{entry}' AND hours=3 AND description='Preserve this booking'")
assert 'SCHEMA_RESTORED' in sql("SELECT 'SCHEMA_RESTORED' WHERE NOT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='ROLLBACK_PROBE')")
assert 'HISTORY_RESTORED' in sql("SELECT 'HISTORY_RESTORED' WHERE NOT EXISTS(SELECT 1 FROM databasechangelog WHERE author='rollback-test')")
if index:
assert 'INTERMEDIATE_BOOKING_PRESERVED' in sql("SELECT 'INTERMEDIATE_BOOKING_PRESERVED' FROM work_entry WHERE description='Written after first rollback' AND hours=2")
rounds.append(dict(candidate=candidate, restoredCommit=old, bookingPreserved=True,
intermediateBookingPreserved=index > 0))
if index + 1 < len(candidates):
# New data after B's rollback must survive C as well. Reusing
# the first deployment's snapshot would lose this entry.
sql(f"INSERT INTO work_entry(id,owner_id,customer_id,project_id,work_date,hours,description) VALUES('10000000-0000-0000-0000-000000000005','{user}','{customer}','{project}',DATE '2026-09-02',2,'Written after first rollback')")
runner.migration_failed = False
runner.start()
runner.ready({'commit': old})
assert not (home / 'unexpected-mail').exists()
if args.fail_rollback:
args.report.write_text(json.dumps(dict(result='EXPECTED_FAILURE', maintenanceRetained=True,
journalRetained=True, rollbackStopped=True, bookingPreserved=True,
mailInvocations=0, pushDisabled=True)) + '\n')
assert failed_rollback is not None
raise failed_rollback
report = dict(result='PASS', partialMigration=True, candidateStopped=True,
matchingBackupRestored=True, bookingPreserved=True, schemaRestored=True,
maintenanceUntilReady=True, mailInvocations=0, pushDisabled=True, rounds=rounds)
args.report.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report))
finally:
runner.stop()
if runner.blocked_port is not None:
runner.blocked_port.close()
gateway.shutdown()
gateway.server_close()
if __name__ == '__main__':
main()