Files
railwaysystem/scripts/qa/qa.py
T

227 lines
11 KiB
Python

#!/usr/bin/env python3
"""Local QA only. No DDL, hardware, account provisioning or shared-service management."""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import secrets
import shlex
import signal
import socket
import stat
import subprocess
import sys
import urllib.parse
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
DATABASE = 'railwaysystem_fusion_qa_20261013'
RUNTIME = ROOT / '.qa-runtime'
JAR = Path.home() / '.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar'
def load_config(path):
path = path.expanduser()
info = path.stat()
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077 or not stat.S_ISREG(info.st_mode):
raise ValueError('Config must be an owner-only regular file (0600)')
values = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
key, value = line.removeprefix('export ').split('=', 1)
tokens = shlex.split(value, comments=True)
if len(tokens) != 1:
raise ValueError('Config requires literal KEY=value; no shell expansion')
values[key] = tokens[0]
return values
def environment(config):
# Legacy QA_DB_URL deliberately NOT reused: it may point to a different QA database.
url = config.get('FUSION_QA_DB_URL', 'jdbc:kingbase8://192.168.139.218:4321/' + DATABASE + '?connectTimeout=5&socketTimeout=15')
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
if parsed.scheme != 'kingbase8' or parsed.path != '/' + DATABASE or parsed.username or parsed.password or parsed.fragment:
raise ValueError('Refusing non-target database URL')
if any(k.lower() in ('user', 'password') for k in urllib.parse.parse_qs(parsed.query)):
raise ValueError('URL credentials forbidden')
password = config.get('FUSION_QA_APP_PASSWORD', '')
if not password:
raise ValueError('Missing FUSION_QA_APP_PASSWORD')
inherited = {key: value for key, value in os.environ.items()
if not key.startswith(('SPRING_', 'IOT_', 'DMS_', 'QA_', 'TOKEN_', 'TRAIN_'))
and key not in ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS']}
return dict(inherited, QA_DB_URL=url, QA_DB_USER='fusion_qa_app', QA_DB_PASSWORD=password)
def verify_snapshot(directory):
directory = directory.resolve()
if not directory.is_relative_to(RUNTIME.resolve()):
raise ValueError('Snapshot must stay inside this task private runtime directory')
expected = json.loads((directory / 'sha256.json').read_text())
for name in ['lock_auth.xml', 'lock_command.xml']:
raw = (directory / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected[name]:
raise ValueError('Snapshot checksum mismatch')
tree = ET.fromstring(raw)
rows = sum(1 for node in tree.iter() if node.tag.rsplit('}', 1)[-1] in ['currentRow', 'insertRow'])
print('SNAPSHOT_PARSE_SHA256_VERIFIED ' + name + ' rows=' + str(rows))
print('NATIVE_DATABASE_RESTORE_NOT_VERIFIED')
def private_directory():
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != os.getuid() or stat.S_IMODE(RUNTIME.stat().st_mode) & 0o077:
raise ValueError('Unsafe runtime directory')
def inspect(env, extra):
command = ['java', '-cp', str(JAR), str(ROOT / 'scripts/qa/QaDatabaseProbe.java'), *extra]
result = subprocess.run(command, env=env, text=True, capture_output=True, timeout=60)
private_directory()
path = RUNTIME / 'database-inspection.log'
with open(path, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
stream.write(result.stdout + result.stderr)
# Probe reports only metadata/counts; SQL errors are reduced to SQLSTATE.
print(result.stdout + result.stderr, end='')
return result.returncode
def available(port):
with socket.socket() as probe:
probe.bind(('127.0.0.1', port))
def launch(name, command, env):
private_directory()
ledger = RUNTIME / (name + '.json')
if ledger.exists():
raise ValueError('Runtime ledger exists; inspect/stop this task-owned process first')
log = open(RUNTIME / (name + '.log'), 'w', opener=lambda p, f: os.open(p, f, 0o600))
child = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
log.close()
started = subprocess.run(['ps', '-p', str(child.pid), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started}))
print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log')))
def start(config, env, backend_port, redis_port):
if inspect(env, ['--require-migrated']):
raise ValueError('Backend not started: QA migration preflight failed')
available(backend_port)
available(redis_port)
private_directory()
secret_path = RUNTIME / 'runtime-secrets.env'
if not secret_path.exists():
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('TOKEN_SECRET=' + base64.b64encode(secrets.token_bytes(64)).decode() + '\n')
f.write('QA_REDIS_PASSWORD=' + secrets.token_urlsafe(40) + '\n')
local_secrets = load_config(secret_path)
env.update(local_secrets)
for key in ['TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY', 'TRAIN_STUDENT_IDENTITY_HASH_KEY']:
if not config.get(key):
raise ValueError('Missing required protected identity configuration')
env[key] = config[key]
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'],
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
redis_config = RUNTIME / 'redis.conf'
with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n')
command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'),
'--spring.profiles.active=druid',
'--spring.datasource.druid.master.url=' + env['QA_DB_URL'],
'--spring.datasource.druid.master.username=fusion_qa_app',
'--server.address=127.0.0.1', '--server.port=' + str(backend_port),
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
'--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false',
'--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=',
'--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false',
'--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
'--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
'--spring.datasource.druid.stat-view-servlet.enabled=false']
if not Path(command[2]).is_file():
raise ValueError('Build backend jar first')
launch('redis', ['redis-server', str(redis_config)], env)
launch('backend', command, env)
def stop():
private_directory()
for name in ['frontend', 'backend', 'redis']:
path = RUNTIME / (name + '.json')
if not path.exists():
continue
item = json.loads(path.read_text())
actual = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'command='], text=True, capture_output=True).stdout.strip()
if actual:
# macOS Python wrappers exec Python.app; Redis rewrites its process title.
# Check task arguments + cwd + birth time, never select by port/name alone.
suffix = ' '.join(item['command'][1:])
cwd = subprocess.run(['lsof', '-a', '-p', str(item['pid']), '-d', 'cwd', '-Fn'], text=True, capture_output=True).stdout
started = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
comm = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'comm='], text=True, capture_output=True).stdout.strip()
identity = actual.endswith(suffix) and suffix != ''
if name == 'redis':
identity = bool(item.get('started')) and Path(comm).name.startswith('redis-server')
if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started):
raise ValueError('PID identity mismatch; refusing to signal ' + name)
os.kill(item['pid'], signal.SIGTERM)
path.unlink()
print('Stopped task-owned ' + name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['inspect', 'snapshot', 'verify-snapshot', 'start', 'frontend', 'stop'])
parser.add_argument('--config', type=Path, default=Path.home() / '.railwaysystem-qa.env')
parser.add_argument('--backend-port', type=int, default=18096)
parser.add_argument('--frontend-port', type=int, default=18088)
parser.add_argument('--redis-port', type=int, default=16389)
parser.add_argument('--snapshot-dir', type=Path)
args = parser.parse_args()
if args.action == 'verify-snapshot':
if not args.snapshot_dir:
raise ValueError('Specify --snapshot-dir')
verify_snapshot(args.snapshot_dir)
return 0
if args.action == 'stop':
stop()
return 0
if args.action == 'frontend':
available(args.frontend_port)
launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'),
'--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], os.environ.copy())
return 0
config = load_config(args.config)
env = environment(config)
if args.action == 'inspect':
return inspect(env, [])
if args.action == 'snapshot':
private_directory()
path = RUNTIME / ('snapshot-' + secrets.token_hex(6))
rc = inspect(env, ['--backup', str(path)])
if rc == 0:
hashes = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in path.glob('*.xml')}
(path / 'sha256.json').write_text(json.dumps(hashes, indent=2))
print('SNAPSHOT_DIRECTORY=' + str(path))
return rc
start(config, env, args.backend_port, args.redis_port)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
# Do not echo environment or configuration values.
print('QA_OPERATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__), file=sys.stderr)
sys.exit(2)