2026-10-14 11:32:31 +08:00
|
|
|
#!/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
|
2026-10-14 18:31:38 +08:00
|
|
|
import shutil
|
2026-10-14 11:32:31 +08:00
|
|
|
import socket
|
|
|
|
|
import stat
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-10-14 18:31:38 +08:00
|
|
|
import time
|
2026-10-14 11:32:31 +08:00
|
|
|
import urllib.parse
|
|
|
|
|
import xml.etree.ElementTree as ET
|
2026-10-15 02:54:00 +08:00
|
|
|
import qa_isolation
|
2026-10-14 11:32:31 +08:00
|
|
|
|
|
|
|
|
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')
|
2026-10-15 02:54:00 +08:00
|
|
|
if parsed.port == qa_isolation.LEGACY_PORT:
|
|
|
|
|
raise ValueError('Legacy database endpoint is forbidden')
|
2026-10-14 11:32:31 +08:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 18:31:38 +08:00
|
|
|
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)))
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 11:32:31 +08:00
|
|
|
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()
|
2026-10-15 02:54:00 +08:00
|
|
|
ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started,
|
|
|
|
|
'isolation_id': env.get('FUSION_QA_ISOLATION_ID')}))
|
2026-10-14 11:32:31 +08:00
|
|
|
print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log')))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start(config, env, backend_port, redis_port):
|
2026-10-15 02:54:00 +08:00
|
|
|
lease = qa_isolation.require(env.get('QA_DB_URL'))
|
|
|
|
|
env['FUSION_QA_ISOLATION_ID'] = lease['isolation_id']
|
2026-10-14 11:32:31 +08:00
|
|
|
if inspect(env, ['--require-migrated']):
|
|
|
|
|
raise ValueError('Backend not started: QA migration preflight failed')
|
|
|
|
|
available(backend_port)
|
|
|
|
|
available(redis_port)
|
|
|
|
|
private_directory()
|
2026-10-14 18:31:38 +08:00
|
|
|
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')
|
2026-10-14 11:32:31 +08:00
|
|
|
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]
|
2026-10-14 18:31:38 +08:00
|
|
|
runtime_url = druid_url(env['QA_DB_URL'])
|
|
|
|
|
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=runtime_url,
|
2026-10-14 11:32:31 +08:00
|
|
|
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')
|
2026-10-14 18:31:38 +08:00
|
|
|
command = ['java', '-jar', str(runtime_jar),
|
2026-10-14 11:32:31 +08:00
|
|
|
'--spring.profiles.active=druid',
|
2026-10-14 18:31:38 +08:00
|
|
|
'--spring.datasource.druid.master.url=' + runtime_url,
|
2026-10-14 11:32:31 +08:00
|
|
|
'--spring.datasource.druid.master.username=fusion_qa_app',
|
2026-10-14 18:31:38 +08:00
|
|
|
'--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',
|
2026-10-14 11:32:31 +08:00
|
|
|
'--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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 18:31:38 +08:00
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 02:54:00 +08:00
|
|
|
def stop(isolation_id=None):
|
2026-10-14 11:32:31 +08:00
|
|
|
private_directory()
|
|
|
|
|
for name in ['frontend', 'backend', 'redis']:
|
|
|
|
|
path = RUNTIME / (name + '.json')
|
|
|
|
|
if not path.exists():
|
|
|
|
|
continue
|
|
|
|
|
item = json.loads(path.read_text())
|
2026-10-15 02:54:00 +08:00
|
|
|
if isolation_id is not None and item.get('isolation_id') != isolation_id:
|
|
|
|
|
raise ValueError('Other isolation lease owns this runtime ledger; refusing to signal it')
|
2026-10-14 11:32:31 +08:00
|
|
|
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)
|
2026-10-14 18:31:38 +08:00
|
|
|
wait_for_exit(item['pid'])
|
2026-10-14 11:32:31 +08:00
|
|
|
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':
|
2026-10-15 02:54:00 +08:00
|
|
|
lease = qa_isolation.require()
|
|
|
|
|
backend = json.loads((RUNTIME / 'backend.json').read_text())
|
|
|
|
|
if backend.get('isolation_id') != lease['isolation_id'] or '--server.port=' + str(args.backend_port) not in backend.get('command', []):
|
|
|
|
|
raise ValueError('Frontend backend belongs to another isolation lease')
|
2026-10-14 11:32:31 +08:00
|
|
|
available(args.frontend_port)
|
2026-10-15 02:54:00 +08:00
|
|
|
env = os.environ.copy()
|
|
|
|
|
env['FUSION_QA_ISOLATION_ID'] = lease['isolation_id']
|
2026-10-14 11:32:31 +08:00
|
|
|
launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'),
|
2026-10-15 02:54:00 +08:00
|
|
|
'--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], env)
|
2026-10-14 11:32:31 +08:00
|
|
|
return 0
|
|
|
|
|
config = load_config(args.config)
|
2026-10-15 02:54:00 +08:00
|
|
|
if args.action == 'start' or (RUNTIME / qa_isolation.LEDGER).exists():
|
|
|
|
|
lease = qa_isolation.require()
|
|
|
|
|
config.setdefault('FUSION_QA_DB_URL', qa_isolation.url_for(lease))
|
2026-10-14 11:32:31 +08:00
|
|
|
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)
|