Smail/test/tools/runtime_sql_switch_fault.py
smail 079ebee495
Some checks are pending
Security Checks / security (push) Waiting to run
补充SQL切换联合故障验收工具并校准系统调用边界
2026-09-22 00:58:02 +08:00

221 lines
11 KiB
Python

"""在隔离应用的真实 SQL 提交与根配置替换边界终止进程。"""
import argparse
import hashlib
import http.client
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import threading
import time
from runtime_interruption import assert_scope
from runtime_migration import clear_pending_restart, login, run_probe, service_state
from runtime_sql import SQLDatabase
from runtime_sql_migration import (
assert_active, assert_connections, connection_count, fingerprint,
history_rows, table_rows, target_payload,
)
def wait_until(predicate, timeout=60):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.02)
raise AssertionError('联合故障验收等待超时')
def prepare(root, password, target):
client, _ = login(root, password)
generation = clear_pending_restart(client)
preview = client.success('/api/v1/database/migrations/preview', {
'expectedGeneration': generation, 'target': target_payload(target),
})
# 令牌与会话只在探针内存中;就绪标记不包含秘密。
(root.parent / 'switch-ready').touch(exist_ok=False)
wait_until(lambda: (root.parent / 'switch-gate').is_file())
try:
client.request('/api/v1/database/migrations', {
'id': preview['migration']['id'], 'token': preview['token'],
})
except (http.client.RemoteDisconnected, ConnectionResetError):
return {'request_disconnected': True}
raise AssertionError('未观察到连接被终止,普通响应或超时不能算进程终止验收')
def traced_tasks(pid, tracer):
tasks = list(Path('/proc/' + str(pid) + '/task').iterdir())
assert tasks
return all('TracerPid:\t' + str(tracer) + '\n' in (task / 'status').read_text() for task in tasks)
def stopped_tasks(pid):
tasks = list(Path('/proc/' + str(pid) + '/task').iterdir())
assert tasks
for task in tasks:
status = (task / 'status').read_text()
state = next(line.split()[1] for line in status.splitlines() if line.startswith('State:'))
if state not in ('T', 't'):
return False
return True
def boundary(root, trace, phase, source, target):
manifests = list((root / 'data/transaction').glob('*/manifest.json'))
if len(manifests) != 1:
return None
manifest = json.loads(manifests[0].read_text())
if manifest['phase'] != 'sections_committed':
return None
text = trace.read_text() if trace.exists() else ''
# 同名归档也会延迟;只有目录描述符明确指向活动配置的调用才可命中。
pattern = r'renameat(?:2)?\(\d+</opt/smail/config>, "[^"\n]+", \d+</opt/smail/config>, "config.json"'
if not re.search(pattern, text):
return None
expected = source if phase == 'before-root' else target
if json.loads((root / 'config/config.json').read_text())['database']['name'] != expected.settings['name']:
return None
return manifest
def assert_committed(source, target):
source_mail, target_mail = table_rows(source, 'email'), table_rows(target, 'email')
assert source_mail and source_mail == target_mail, '独立连接没有看到完整已提交邮件'
source_history, target_history = history_rows(source), history_rows(target)
assert source_history and set(source_history).issubset(target_history), '目标缺少已提交配置历史'
return {'committed_mail_rows': len(target_mail), 'committed_history_rows': len(target_history),
'mail_equal': True, 'source_history_preserved': True}
def verify_switch_fault(root, unit, password, result, source, target, phase):
assert_scope(root, unit)
assert phase in ('before-root', 'after-root') and source.kind == target.kind
assert source.settings['name'] != target.settings['name']
details = result['migration'] = {'passed': False, 'fault_phase': phase, 'database_type': source.kind}
settings = {'source': source.settings, 'target': target.settings}
outcome = {}
thread = trace = None
frozen = False
trace_path = root.parent / 'switch-trace.log'
def request():
try:
outcome['probe'] = run_probe(root, unit, password, 'switch-request', settings,
probe_script='runtime_sql_switch_fault.py')
except Exception as error:
outcome['error'] = type(error).__name__
try:
initial = service_state(unit)
pid = initial['MainPID']
assert pid.isdigit() and int(pid) > 0
assert Path('/proc/' + pid + '/root/opt/smail/smail').samefile(root / 'smail')
assert Path('/proc/' + pid + '/ns/net').readlink() != Path('/proc/self/ns/net').readlink()
thread = threading.Thread(target=request, daemon=True)
thread.start()
wait_until(lambda: (root.parent / 'switch-ready').exists())
assert thread.is_alive() and service_state(unit)['MainPID'] == pid
assert_active(root, source)
assert list((root / 'data/transaction').iterdir()) == []
with (root.parent / 'switch-trace.stderr').open('w') as errors:
delay = 'enter' if phase == 'before-root' else 'exit'
trace = subprocess.Popen(['strace', '-f', '-ttt', '-T', '-yy',
'-e', 'trace=rename,renameat,renameat2', '-P', 'config.json',
'-e', 'inject=rename,renameat,renameat2:delay_' + delay + '=6s',
'-o', str(trace_path), '-p', pid], stdout=subprocess.DEVNULL, stderr=errors)
wait_until(lambda: traced_tasks(pid, trace.pid), timeout=10)
(root.parent / 'switch-gate').touch(exist_ok=False)
wait_until(lambda: boundary(root, trace_path, phase, source, target), timeout=55)
assert thread.is_alive() and trace.poll() is None
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGSTOP', unit], check=True, timeout=15)
frozen = True
wait_until(lambda: stopped_tasks(pid), timeout=5)
manifest = boundary(root, trace_path, phase, source, target)
assert manifest is not None, '冻结后故障边界已经变化'
details['committed'] = assert_committed(source, target)
active, offline = (source, target) if phase == 'before-root' else (target, source)
assert_active(root, active)
offline_digest = fingerprint(offline)
details['observed'] = {'manifest_phase': manifest['phase'],
'from_generation': manifest['fromGeneration'], 'to_generation': manifest['toGeneration'],
'root_database': active.settings['name'], 'active_root_syscall_observed': True,
'all_application_threads_stopped': True}
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit], check=True, timeout=15)
frozen = False
wait_until(lambda: service_state(unit)['MainPID'] == '0')
killed = service_state(unit)
assert killed['ExecMainStatus'] == '9'
trace.wait(timeout=15)
thread.join(timeout=35)
assert not thread.is_alive() and 'error' not in outcome and outcome['probe']['request_disconnected']
assert connection_count(source) == connection_count(target) == 0
assert fingerprint(offline) == offline_digest
target.revoke_creation()
recovery_settings = {'source': offline.settings, 'target': active.settings}
subprocess.run(['systemctl', 'start', unit], check=True, timeout=45)
details['recovered'] = run_probe(root, unit, password, 'recovered', recovery_settings)
recovered = service_state(unit)
assert recovered['MainPID'] not in ('0', pid)
assert recovered['InvocationID'] != initial['InvocationID']
details['connections'] = assert_connections(offline, active)
assert fingerprint(offline) == offline_digest
assert list((root / 'data/transaction').iterdir()) == []
assert list((root / 'data/transaction-staging').iterdir()) == []
# 再次从磁盘启动,证明恢复结果可重复使用,不只依赖首次恢复的内存状态。
subprocess.run(['systemctl', 'restart', unit], check=True, timeout=45)
details['second_restart'] = run_probe(root, unit, password, 'process', recovery_settings)
final = service_state(unit)
assert final['MainPID'] not in ('0', pid, recovered['MainPID'])
assert fingerprint(offline) == offline_digest
assert_connections(offline, active)
details.update(passed=True, offline_database_unchanged=True,
temporary_creation_privilege_revoked=True,
killed_state=killed, recovered_state=recovered, final_state=final,
trace_sha256=hashlib.sha256(trace_path.read_bytes()).hexdigest())
finally:
# 异常路径不让带有延迟器的迁移继续执行;仅停止已经核验的隔离应用。
if trace is not None and trace.poll() is None:
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit],
capture_output=True, timeout=15)
try:
trace.wait(timeout=15)
except subprocess.TimeoutExpired:
trace.send_signal(signal.SIGTERM)
trace.wait(timeout=10)
elif frozen:
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit],
capture_output=True, timeout=15)
if thread is not None:
thread.join(timeout=90)
(root.parent / 'migration-result.json').write_text(
json.dumps(details, ensure_ascii=False, indent=2), encoding='utf-8')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('root', type=Path)
parser.add_argument('phase', choices=('switch-request',))
args = parser.parse_args()
assert os.geteuid() != 0
password = sys.stdin.readline().strip()
assert len(password) == 12
settings = json.loads(sys.stdin.readline())
assert set(settings) == {'source', 'target'}
source, target = (SQLDatabase(settings[key]) for key in ('source', 'target'))
assert source.kind == target.kind and source.settings['name'] != target.settings['name']
result = prepare(args.root, password, target)
result['passed'] = True
(args.root.parent / ('migration-' + args.phase + '.json')).write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
main()