Files
2026-09-09 14:26:09 +02:00

84 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prove an operator backup restores in an isolated loopback JVM; no production mutation."""
import argparse
import io, json, os, runpy, shutil, socket, subprocess, tempfile, time, urllib.request, zipfile
from pathlib import Path
root=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--backup', type=Path, required=True)
parser.add_argument('--java', type=Path, default=Path(os.environ.get('JAVA_HOME', '/usr'))/'bin/java')
args=parser.parse_args()
scope=runpy.run_path(str(root/'scripts/deploy-update-remote.py'))
scope.update(runpy.run_path(str(root/'scripts/backup.py')))
restore=runpy.run_path(str(root/'scripts/restore-remote.py'), init_globals=scope)
archive=args.backup.resolve(strict=True)
metadata=scope['verify'](archive)
java=args.java.resolve(strict=True)
(root/'work').mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='restore-proof-', dir=root/'work') as temporary:
home=Path(temporary)
class Local(scope['Deployment']):
def __init__(self):
super().__init__(home)
self.process=None
self.site.mkdir(parents=True)
with socket.socket() as sock:
sock.bind(('127.0.0.1',0));self.port=sock.getsockname()[1]
def preflight(self, check_data=True, allow_restore=False):
if not allow_restore and (self.app/'pending-restore.json').exists(): raise RuntimeError('pending restore')
def command(self,*args):
if '--property=ActiveState' in args:return 'active' if self.process and self.process.poll() is None else 'inactive'
return '0'
def maintenance(self):self.flag.write_text('isolated maintenance')
def start(self):
if self.process and self.process.poll() is None:return
with (home/'application.log').open('ab') as log:
self.process=subprocess.Popen([str(java),'-Xmx256m','-XX:MaxMetaspaceSize=192m','-XX:ActiveProcessorCount=2','-jar',str(self.app/'current/werkjournal.jar'),f'--server.port={self.port}','--server.address=127.0.0.1','--werkjournal.guest-mode=true','--werkjournal.push.enabled=false','--werkjournal.mail.sendmail=/bin/false'],cwd=self.app,env={'HOME':str(home),'PATH':'/usr/bin:/bin','LANG':'C.UTF-8'},stdout=log,stderr=subprocess.STDOUT)
def stop(self):
if self.process and self.process.poll() is None:
self.process.terminate()
try:self.process.wait(timeout=30)
except subprocess.TimeoutExpired:self.process.kill();self.process.wait();raise
def ready(self,expected):
deadline=time.monotonic()+90
while time.monotonic()<deadline:
if self.process.poll() is not None:raise RuntimeError('isolated application failed')
try:
with urllib.request.urlopen(f'http://127.0.0.1:{self.port}/api/v1/info',timeout=2) as response:value=json.load(response)
if value['version']!=expected['version']:raise RuntimeError('wrong version')
with urllib.request.urlopen(f'http://127.0.0.1:{self.port}/?v-r=init&location=&query=',timeout=3) as response:flow=json.load(response)
if not isinstance(flow.get('appConfig'),dict):raise RuntimeError('Flow unavailable')
return
except (OSError,ValueError):time.sleep(.25)
raise RuntimeError('isolated readiness timeout')
d=Local()
source=home/'source';source.mkdir()
import tarfile
with tarfile.open(archive,'r:gz') as tar:
for entry in tar:
path=source/entry.name;path.parent.mkdir(parents=True,exist_ok=True)
with tar.extractfile(entry) as src,path.open('wb') as dst:shutil.copyfileobj(src,dst)
release=d.app/'releases'/metadata['release']['commit'];release.parent.mkdir(parents=True)
shutil.copytree(source/'release',release)
(d.app/'current').symlink_to(release)
shutil.copytree(source/'data',d.app/'data')
(d.app/'deployed.json').write_text(json.dumps(metadata['release']))
with zipfile.ZipFile(release/'werkjournal.jar') as jar:
name=next(n for n in jar.namelist() if n.startswith('BOOT-INF/lib/h2-'))
h2=home/'h2.jar';h2.write_bytes(jar.read(name))
probe=home/'Probe.java';probe.write_text('''import java.sql.*; class Probe { public static void main(String[] a) throws Exception { try(var c=DriverManager.getConnection(a[0],"sa", "");var s=c.createStatement()){ if(a[1].equals("seed")){s.execute("CREATE TABLE WJ_RESTORE_PROBE(ID INT PRIMARY KEY)");s.execute("INSERT INTO WJ_RESTORE_PROBE VALUES(1)");}else{try(var r=s.executeQuery("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='WJ_RESTORE_PROBE'")){r.next();if(r.getInt(1)!=(a[1].equals("present")?1:0))throw new AssertionError("restore marker mismatch");}}}}}''')
def check(directory, mode):
result=subprocess.run([str(java),'-cp',str(h2),str(probe),'jdbc:h2:file:'+str(directory/'werkjournal')+';IFEXISTS=TRUE',mode],stdout=subprocess.PIPE,stderr=subprocess.PIPE)
if result.returncode:raise RuntimeError('isolated JDBC verification failed')
check(d.app/'data','seed')
try:
d.start();d.ready(metadata['release'])
outcome=restore['OperatorRestore'](d).restore(archive)
d.stop()
check(d.app/'data','absent')
check(Path(outcome['priorSnapshot'])/'data','present')
assert not (d.app/'pending-restore.json').exists()
assert not d.flag.exists()
print(json.dumps({'result':'PASS','version':metadata['release']['version'],'restoredDatabase':'verified','priorDatabase':'verified','flow':'PASS','productionChanged':False}))
finally:d.stop()