84 lines
5.6 KiB
Python
Executable File
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()
|