#!/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 shutil import socket import stat import subprocess import sys import time 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 druid_url(url): # Druid 1.2.28 also parses URL timeout values as pool milliseconds, whereas # Kingbase JDBC uses seconds. Keep pool timeout configuration in one place. parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:')) query = [(k,v) for k,v in urllib.parse.parse_qsl(parsed.query) if k.lower() not in ('connecttimeout', 'sockettimeout')] return 'jdbc:' + urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) 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() built_jar = ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar' if not built_jar.is_file(): raise ValueError('Build backend jar first') digest = hashlib.sha256(built_jar.read_bytes()).hexdigest() runtime_jar = RUNTIME / ('ruoyi-admin-' + digest[:16] + '.jar') if not runtime_jar.exists(): shutil.copyfile(built_jar, runtime_jar) if hashlib.sha256(runtime_jar.read_bytes()).hexdigest() != digest: raise ValueError('Runtime artifact hash mismatch') 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] runtime_url = druid_url(env['QA_DB_URL']) env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=runtime_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(runtime_jar), '--spring.profiles.active=druid', '--spring.datasource.druid.master.url=' + runtime_url, '--spring.datasource.druid.master.username=fusion_qa_app', '--spring.datasource.druid.master.connect-timeout=30000', '--spring.datasource.druid.master.socket-timeout=60000', '--spring.datasource.druid.master.initial-size=2', '--spring.datasource.druid.master.min-idle=2', '--spring.datasource.druid.master.max-active=8', '--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 wait_for_exit(pid): deadline = time.monotonic() + 15 while time.monotonic() < deadline: state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], text=True, capture_output=True).stdout.strip() if not state or state.startswith('Z'): return time.sleep(0.1) raise ValueError('Task-owned process did not stop; ledger retained, no forced kill') 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) wait_for_exit(item['pid']) 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)