fix: 收口融合 QA 构建与验收安全基线
This commit is contained in:
1 parent
47bc6604e5
commit
5560a3ac04
28 files changed
+22947
-352
No files matched your search
@@ -0,0 +1,226 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user