补齐SQLite复制中断恢复及云端部署验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 06:27:57 +08:00
parent 2de629fb79
commit d8b5a5fb23
7 changed files with 405 additions and 5 deletions

View File

@ -0,0 +1,64 @@
# SQLite 复制中断恢复补充验收
## 结论和范围
北京时间 2026 年 9 月 21 日,云端隔离实例完成 SQLite 未提交复制事务中的真实进程中断恢复。随后在同一实例完成正常迁移、内部重启、系统级进程重启和目标权限故障恢复。本项复审通过,但不代表整个项目达到生产放行条件。
- `[✓]` 复制事务未提交时发送 SIGKILL,源库和活动配置保持不变。
- `[✓]` 恢复后登录、历史邮件正文及附件、新邮件 SMTP 收取及 IMAP/POP3 读取通过。
- `[✓]` 再使用中断目标返回 HTTP 409 和结构化错误码,失败目标及日志文件未被覆盖。
- `[✓]` 后续正常迁移只保留一个活动业务库,配置历史与业务同库。
- `[✓]` 扩充部署套件实际执行 131 项,零跳过、零失败。
- `[ ]` 提交临界点、配置切换临界点、MySQL/PostgreSQL 复制中断及跨引擎迁移矩阵。
## 实例、候选和取证
成功实例:`/var/tmp/smail-review-runtime-20260921-061711/`。
本地归档:`test-log/production-remediation-20260921/interruption-runtime-evidence/smail-review-runtime-20260921-061711/`。
驱动汇总:`interruption-runtime-result-20260921-061701.json`。
候选程序沿用此前重新编译的 `smail-sql-migration`,SHA-256 为 `6b700120e8bc0b6ca1ce723d0319852259d4aa983646bbc69c4c2fe73e88309d`,源码标识为 `375ffcabfea607690ea89e31b72cc346cfc122f3`。本轮只修改验收工具和测试,不宣称重新构建应用或前端。
`interruption-recheck-20260921-062557.json` 再次核对 610 个后端 Go 源文件、四个本地与远端工具以及 20 份下载证据的摘要。工具清单对应本轮中断支持版本,不套用旧 SQL 验收工具摘要。
## 中断点与恢复断言
以非特权应用身份在隔离源库写入 1,024 条夹具邮件,每条正文 16 KiB;源库大小 17,645,568 字节。通过正式迁移 API 发起复制,检测目标文件和回滚日志后冻结本轮应用进程,确认活动配置仍指向源库且事务未提交,再保存快照并终止进程。
| 取证项 | 实际值 |
| --- | --- |
| 中断进程 PID | 779623 |
| 目标文件大小 | 2,650,112 字节 |
| 回滚日志大小 | 38,400 字节 |
| 目标读取被锁定 | 是 |
| 源库写锁仍被持有 | 是 |
| 已提交目标邮件行数 | 未能查询,结果为 `null`,不是查询得到零行 |
| 终止结果 | MainPID 为零,退出信号 9 |
| 恢复后 PID | 779737 |
| 后续系统重启 PID | 779864 |
| 目标权限故障恢复 PID | 779902 |
中断前后源库完整逻辑摘要及活动配置文件摘要一致。恢复后拒绝复用失败目标,要求 `data.code=DATABASE_MIGRATION_TARGET_EXISTS`,不能把任意 HTTP 409 当作通过。失败目标及 journal 的字节摘要在恢复探针前后保持一致。
权限为 `0700` 的中断快照目录保留原始证据:
- `interrupted.db`:`3bb4c0c1571b9c28a38ce02f6a4dd59982a0f036765f404e8123284790d30369`。
- `interrupted.db-journal`:`4dda08e81d6a036d7f3e4f09040809f4e057845e3276e1472d921f1861fe2c05`。
最终活动库仅 `migrated.db`。`smail.db`、`occupied.db`、`interrupted.db` 是保留的离线源库和失败目标,不是额外活动库。配置历史源、目标各一条,完整行一致。应用内存峰值 76,910,592 字节、交换为零,OOM、资源采集错误、收尾错误均为零。
## 保留的失败记录
1. `060953`:夹具先停止瞬时服务,导致单元被回收,无法再次启动。改为通过非特权探针在独占事务中准备隔离源库,不停止瞬时服务。
2. `061252`:已完成真实中断和源库不变验证,但恢复探针错误地把响应 `data` 当作字符串。修正为检查结构化错误码,并增加错误形状反例;没有降低 HTTP 状态和错误码要求。
3. `061711`:完整中断恢复及后续正常迁移全部通过。前两次失败证据仍保留,不计作完整通过。
## 扩充部署套件和保护检查
本轮新增六项中断工具测试。2026 年 9 月 21 日 06:23 的完整云端套件在 `/var/tmp/smail-review-python-20260921-062339/` 执行,实际 131 项、零跳过、退出零,测试耗时 6.501 秒,单元运行约 7.367 秒,内存峰值 46.3 MiB,交换为零。
本地日志 `python-linux-tests-20260921-062339.log` 的 SHA-256:`5ede614792bb83bb260992295720f5d1423a57cf13f87b7d715d9b1352a734d5`。隔离策略保持不变,仅沿用已经验证的边界内 `CAP_SETUID` 显式传递。
06:25 再次确认六个保护服务的 PID、InvocationID 和重启计数与实机验收前一致,无运行中的验收单元。正式程序 SHA-256 仍为 `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f`。未改动用户原有全项目报告,未删除数据库、快照或其他项目文件。
本记录不覆盖真实浏览器、历史凭据撤销、全部静态告警、真实 DNS 自动续期与全协议换证、外部邮件最终收件、长时容量和异机灾备。上述门禁不得随本次提交自动改为通过。

View File

@ -84,3 +84,7 @@
北京时间 9 月 21 日 05:37 至 05:45 左右,另建 MySQL、PostgreSQL 隔离源库和目标账号,使用重新编译的候选程序完成同引擎切库、内部重启、系统级进程重启、目标账号不可用时拒绝启动及恢复后的真实邮件读写。三阶段均证明源库活动连接为零、目标库有应用连接,业务与配置历史同库,运行目录没有 SQLite 文件。
完整范围、候选摘要、进程标识、资源与清理结果见 `SQL切库与部署回归补充验收.md`。前文 `052137`、`052322` 两轮仍仅代表当时的 SQL 基础回归,不把后来新增的证据倒填为旧测试已经覆盖。复制/提交途中进程中断与跨引擎应用级矩阵仍待验收。
## 后续 SQLite 复制中断补充
9 月 21 日 06:17 的新空隔离实例完成未提交复制事务中的 SIGKILL 中断及恢复,随后正常迁移和重启恢复也通过。详见 `SQLite复制中断恢复补充验收.md`,包含两个夹具失败记录、原始目标及 journal 快照摘要、20 份证据复核与 131 项部署回归。本项仅关闭 SQLite 未提交复制阶段,不关闭提交临界点、配置切换临界点、SQL 数据库中断或跨引擎矩阵。

View File

@ -316,3 +316,9 @@ SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系
云端扩大 Python 部署测试在修正隔离运行器的能力传递后,125 项、零跳过、退出零。此前 Windows 平台不兼容、非特权夹具失败、隔离 Nginx 降权失败的日志均保留。修正只为本轮隔离单元显式传递原有能力边界内的 `CAP_SETUID`,没有关闭隔离、改变正式 Nginx 或降低应用安全检查。
这不是全项目验收完成:同引擎切库不等于跨引擎迁移矩阵通过;扩大部署测试不等于真实 DNS 自动续期、全协议换证、浏览器交互或外部投递通过。迁移中断、历史凭据轮换、其余安全告警及其他生产门禁继续保留。
### 9 月 21 日 SQLite 复制中断补充
详见 `SQLite复制中断恢复补充验收.md`。未提交复制事务中的真实 SIGKILL 中断、源库和配置保持不变、恢复后的登录与真实邮件读写、失败目标拒绝覆盖已通过。随后正常迁移及两类重启、目标权限故障恢复通过,最终活动业务库只有 `migrated.db`,配置历史同库;离线源库和失败目标仅保留为证据。
扩充云端部署套件实际执行 131 项、零跳过、退出零;20 份中断实机证据、四个工具及 610 个后端源码摘要已复核。正式服务与其他项目保持不变,未替换正式程序。SQLite 提交及配置切换临界点、SQL 中断、跨引擎矩阵和前述其他生产门禁仍未关闭,不能宣称整体验收通过。

View File

@ -0,0 +1,78 @@
"""中断验收的范围、种子数据和误报防护测试,不终止任何真实服务。"""
from contextlib import closing
import http.client
from pathlib import Path
import sqlite3
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
import runtime_interruption as interruption
class RuntimeInterruptionTest(unittest.TestCase):
def test_occupied_target_requires_structured_error_contract(self):
payload = {'errorNo': 409, 'data': {'code': 'DATABASE_MIGRATION_TARGET_EXISTS'}}
self.assertEqual(interruption.assert_occupied_target(409, payload)['status'], 409)
for status, invalid in ((200, payload), (409, None),
(409, {'errorNo': 409, 'data': 'DATABASE_MIGRATION_TARGET_EXISTS'}),
(409, {'errorNo': 409, 'data': {'code': 'OTHER'}}),
(409, {'errorNo': 0, 'data': payload['data']})):
with self.assertRaises(AssertionError):
interruption.assert_occupied_target(status, invalid)
def test_formal_instance_rejected_before_service_command(self):
with patch.object(interruption, 'service_state') as state, patch.object(interruption.subprocess, 'run') as run:
with self.assertRaises(AssertionError):
interruption.verify_interruption(Path('/opt/smail'), 'smail.service', 'not-a-secret', {})
state.assert_not_called()
run.assert_not_called()
def test_seed_missing_database_is_not_created(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'data').mkdir()
with self.assertRaises(FileNotFoundError):
interruption.seed_source(root)
self.assertFalse((root / 'data/smail.db').exists())
def test_seed_preserves_existing_mail_and_schema(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'data').mkdir()
path = root / 'data/smail.db'
with closing(sqlite3.connect(path)) as database:
database.execute('CREATE TABLE email (id INTEGER PRIMARY KEY, msg_id TEXT UNIQUE, text TEXT)')
database.execute("INSERT INTO email VALUES (1, 'original', 'body')")
database.commit()
result = interruption.seed_source(root)
self.assertEqual(result['added_rows'], 1024)
with closing(sqlite3.connect(path)) as database:
self.assertEqual(database.execute('SELECT COUNT(*) FROM email').fetchone()[0], 1025)
self.assertEqual(database.execute('SELECT * FROM email WHERE id=1').fetchone(), (1, 'original', 'body'))
self.assertEqual(database.execute('SELECT COUNT(*) FROM sqlite_master WHERE type=\'table\'').fetchone()[0], 1)
def test_completed_request_cannot_count_as_interruption(self):
client = Mock()
client.success.return_value = {'migration': {'id': 'one'}, 'token': 'not-a-secret'}
for response in ((200, {}, {}), (500, {}, {})):
client.request.return_value = response
with patch.object(interruption, 'login', return_value=(client, None)), \
patch.object(interruption, 'clear_pending_restart', return_value=1):
with self.assertRaises(AssertionError):
interruption.prepare(Path('/unused'), 'not-a-secret')
def test_disconnected_request_requires_controller_process_evidence(self):
client = Mock()
client.success.return_value = {'migration': {'id': 'one'}, 'token': 'not-a-secret'}
client.request.side_effect = http.client.RemoteDisconnected()
with patch.object(interruption, 'login', return_value=(client, None)), \
patch.object(interruption, 'clear_pending_restart', return_value=1):
self.assertEqual(interruption.prepare(Path('/unused'), 'not-a-secret'), {'request_interrupted': True})
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,232 @@
"""在隔离 SQLite 实例复制事务未提交时终止进程,保留残留目标并验证恢复。"""
import argparse
from contextlib import closing
from email.message import EmailMessage
import hashlib
import http.client
import json
import os
from pathlib import Path
import re
import shutil
import sqlite3
import subprocess
import sys
import threading
import time
from runtime_migration import (
clear_pending_restart, database_fingerprint, login, read_database,
root_database, run_probe, service_state,
)
from runtime_probe import ACCOUNT, ADDRESS, smtp_connect, verify_protocol_read
def assert_scope(root, unit):
assert root.parent.parent == Path('/var/tmp')
assert re.fullmatch(r'smail-review-runtime-\d{8}-\d{6}', root.parent.name)
assert root.name == 'instance' and root.resolve(strict=True) == root
assert unit == root.parent.name.replace('smail-review-runtime-', 'smail-runtime-review-')
assert (root / 'data').resolve(strict=True) == root / 'data'
def seed_source(root):
path = root / 'data/smail.db'
assert path.resolve(strict=True) == path and path.is_file()
# 使用本轮服务身份和独占写事务扩充已有邮件表,不新增结构,不连接正式数据库。
with closing(sqlite3.connect(path.as_uri() + '?mode=rw', uri=True)) as database:
database.execute('BEGIN IMMEDIATE')
columns = [row[1] for row in database.execute('PRAGMA table_info(email)')]
assert all(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) for name in columns)
sample = database.execute('SELECT * FROM email ORDER BY id LIMIT 1').fetchone()
assert sample and {'id', 'msg_id', 'text'}.issubset(columns)
before = database.execute('SELECT COUNT(*) FROM email').fetchone()[0]
statement = ('INSERT INTO email (' + ','.join('"' + name + '"' for name in columns)
+ ') VALUES (' + ','.join('?' for _ in columns) + ')')
for index in range(1024):
row = list(sample)
row[columns.index('id')] = None
row[columns.index('msg_id')] = 'interruption-fill-' + str(index) + '@runtime.invalid.internal'
row[columns.index('text')] = 'x' * 16384
database.execute(statement, row)
database.commit()
assert database.execute('SELECT COUNT(*) FROM email').fetchone()[0] == before + 1024
return {'added_rows': 1024, 'text_bytes_per_row': 16384, 'database_bytes': path.stat().st_size}
def assert_copy_uncommitted(root):
assert root_database(root) == 'smail.db'
target = root / 'data/interrupted.db'
journal = root / 'data/interrupted.db-journal'
assert target.stat().st_size > 2 * 1024 * 1024 and journal.stat().st_size > 512
assert target.stat().st_size < (root / 'data/smail.db').stat().st_size
# 页面溢写可能取得排他锁;独立读取被锁定或只见零行,均不能看到已提交复制。
committed_rows = None
target_locked = False
with closing(sqlite3.connect(target.as_uri() + '?mode=ro', uri=True, timeout=0)) as database:
try:
committed_rows = database.execute('SELECT COUNT(*) FROM email').fetchone()[0]
assert committed_rows == 0
except sqlite3.OperationalError as error:
assert 'locked' in str(error).lower()
target_locked = True
with closing(sqlite3.connect((root / 'data/smail.db').as_uri() + '?mode=rw', uri=True, timeout=0)) as database:
try:
database.execute('BEGIN IMMEDIATE')
except sqlite3.OperationalError as error:
assert 'locked' in str(error).lower()
else:
database.rollback()
raise AssertionError('复制期间源库没有写锁')
return {'target_bytes': target.stat().st_size, 'journal_bytes': journal.stat().st_size,
'committed_target_mail_rows': committed_rows, 'target_read_locked': target_locked,
'source_write_locked': True}
def prepare(root, password):
client, _ = login(root, password)
generation = clear_pending_restart(client)
preview = client.success('/api/v1/database/migrations/preview', {
'expectedGeneration': generation, 'target': {'type': 'sqlite', 'localRef': 'interrupted.db'},
})
try:
client.request('/api/v1/database/migrations', {
'id': preview['migration']['id'], 'token': preview['token'],
})
except (OSError, http.client.HTTPException):
return {'request_interrupted': True}
raise AssertionError('请求没有被进程终止中断,不能算作中途故障验收')
def assert_occupied_target(status, payload):
assert status == 409 and isinstance(payload, dict) and payload.get('errorNo') != 0
data = payload.get('data')
assert isinstance(data, dict) and data.get('code') == 'DATABASE_MIGRATION_TARGET_EXISTS'
return {'status': status, 'code': data['code']}
def verify_recovery(root, password):
client, context = login(root, password)
assert root_database(root) == 'smail.db'
status = client.success('/api/v1/config/status', method='GET')
assert not status['pendingRestart']
retained = {path.name: hashlib.sha256(path.read_bytes()).hexdigest()
for path in (root / 'data').glob('interrupted.db*')}
denied, body, _ = client.request('/api/v1/database/migrations/preview', {
'expectedGeneration': status['activeGeneration'],
'target': {'type': 'sqlite', 'localRef': 'interrupted.db'},
})
rejection = assert_occupied_target(denied, body)
assert retained == {path.name: hashlib.sha256(path.read_bytes()).hexdigest()
for path in (root / 'data').glob('interrupted.db*')}
codes = {protocol: client.success('/api/v1/security/credentials/protocols/generate',
{'protocol': protocol})['code'] for protocol in ('smtp', 'imap', 'pop3')}
original = json.loads((root.parent / 'probe-result.json').read_text())['smtp']['message_id']
content = '内部25端口真实收信,检查中文正文和附件。'
attachment = '隔离验收附件内容'.encode('utf-8')
historical = verify_protocol_read(context, codes, original, content, attachment)
message_id = original + '.interruption'
message = EmailMessage()
message['From'], message['To'] = 'interruption@sender.invalid', ADDRESS
message['Subject'] = '迁移中断恢复后的中文邮件验收'
message['Message-ID'] = '<' + message_id + '>'
message.set_content(content)
message.add_attachment(attachment, maintype='text', subtype='plain', filename='验收附件.txt')
with smtp_connect(context) as smtp:
assert not smtp.send_message(message)
received = verify_protocol_read(context, codes, message_id, content, attachment)
with read_database(root / 'data/smail.db') as database:
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 1
assert database.execute('SELECT COUNT(*) FROM config_records').fetchone()[0] > 0
return {'login': True, 'occupied_target_rejected': rejection, 'retained_target_unchanged': True,
'historical_mail': historical,
'new_mail': received, 'source_accepts_writes': True}
def verify_interruption(root, unit, password, result):
assert_scope(root, unit)
details = result['interruption'] = {'passed': False}
before = service_state(unit)
details['seed'] = run_probe(root, unit, password, 'interrupt-seed', probe_script='runtime_interruption.py')
active = service_state(unit)
assert active['MainPID'] != '0' and active['MainPID'] == before['MainPID']
assert Path('/proc/' + active['MainPID'] + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino
outcome = {}
def request():
try:
outcome['probe'] = run_probe(root, unit, password, 'interrupt-copy', probe_script='runtime_interruption.py')
except Exception as error:
outcome['error'] = type(error).__name__
thread = threading.Thread(target=request, daemon=True)
thread.start()
frozen = False
try:
target = root / 'data/interrupted.db'
journal = root / 'data/interrupted.db-journal'
for _ in range(15000):
if target.exists() and target.stat().st_size > 2 * 1024 * 1024 and journal.exists() and journal.stat().st_size > 512:
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGSTOP', unit], check=True, timeout=15)
frozen = True
break
assert thread.is_alive(), '复制请求提前退出,未注入中断'
time.sleep(0.005)
else:
raise AssertionError('没有捕捉到未提交复制阶段,不能把普通重启算作中断')
details['copy'] = assert_copy_uncommitted(root)
digest = database_fingerprint(root / 'data/smail.db')
config_digest = hashlib.sha256((root / 'config/config.json').read_bytes()).hexdigest()
snapshot = root.parent / 'interruption-snapshot'
snapshot.mkdir(mode=0o700)
for path in (target, journal):
shutil.copy2(path, snapshot / path.name)
details['snapshot_sha256'] = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in snapshot.iterdir()}
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit], check=True, timeout=15)
frozen = False
for _ in range(100):
failed = service_state(unit)
if failed['MainPID'] == '0':
break
time.sleep(0.1)
assert failed['MainPID'] == '0' and failed['ExecMainStatus'] == '9'
thread.join(timeout=30)
assert not thread.is_alive() and 'error' not in outcome and outcome['probe']['request_interrupted']
assert database_fingerprint(root / 'data/smail.db') == digest
assert hashlib.sha256((root / 'config/config.json').read_bytes()).hexdigest() == config_digest
details.update(source_unchanged=True, configuration_unchanged=True, killed_state=failed)
finally:
if frozen:
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGCONT', unit], check=True, timeout=15)
thread.join(timeout=90)
subprocess.run(['systemctl', 'start', unit], check=True, timeout=45)
details['recovery'] = run_probe(root, unit, password, 'interrupt-recovery', probe_script='runtime_interruption.py')
restored = service_state(unit)
assert restored['MainPID'] not in ('0', active['MainPID'])
assert restored['InvocationID'] != active['InvocationID']
details.update(before=active, recovered_state=restored, passed=True)
(root.parent / 'interruption-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=('interrupt-seed', 'interrupt-copy', 'interrupt-recovery'))
args = parser.parse_args()
assert os.geteuid() != 0
password = sys.stdin.readline().strip()
assert len(password) == 12
if args.phase == 'interrupt-seed':
result = seed_source(args.root)
elif args.phase == 'interrupt-copy':
result = prepare(args.root, password)
else:
result = verify_recovery(args.root, password)
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()

View File

@ -48,17 +48,20 @@ def root_database(root):
return database['localRef']
def migration_integrity(root):
def migration_integrity(root, interrupted=False):
active = root_database(root)
assert active == 'migrated.db'
names = sorted(path.name for path in (root / 'data').glob('*.db'))
assert names == ['migrated.db', 'occupied.db', 'smail.db'], '发现意外数据库文件'
expected = ['migrated.db', 'occupied.db', 'smail.db']
if interrupted:
expected.append('interrupted.db')
assert names == sorted(expected), '发现意外数据库文件'
with read_database(root / 'data' / active) as database:
assert database.execute('PRAGMA integrity_check').fetchone() == ('ok',)
count = database.execute('SELECT COUNT(*) FROM config_records').fetchone()[0]
assert count > 0
return {'active_database': active, 'config_records': count,
'retained_offline_files': ['smail.db', 'occupied.db'], 'integrity': 'ok'}
'retained_offline_files': sorted(set(expected) - {active}), 'integrity': 'ok'}
def wait_ready(client, generation=None):
@ -194,13 +197,16 @@ def process_uses_target(root, state):
assert '/opt/smail/data/smail.db' not in paths, '进程仍然打开旧业务库'
def run_probe(root, unit, password, phase, sql_settings=None):
def run_probe(root, unit, password, phase, sql_settings=None, probe_script=None):
state = service_state(unit)
pid = state['MainPID']
assert pid.isdigit() and int(pid) > 0
assert Path('/proc/' + pid + '/ns/net').readlink() != Path('/proc/self/ns/net').readlink()
probe = 'smail-migration-probe-' + unit.removeprefix('smail-runtime-review-') + '-' + phase
script = 'runtime_sql_migration.py' if sql_settings else 'runtime_migration.py'
if probe_script is not None:
assert probe_script == 'runtime_interruption.py' and sql_settings is None
script = probe_script
command = ['systemd-run', '--wait', '--pipe', '--unit=' + probe,
'--property=User=smail', '--property=Group=smail',
'--property=NetworkNamespacePath=/proc/' + pid + '/ns/net',

View File

@ -80,8 +80,11 @@ def main():
parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128)
parser.add_argument('--restore', action='store_true')
parser.add_argument('--migration', action='store_true')
parser.add_argument('--interruption', action='store_true')
parser.add_argument('--database-type', choices=('sqlite', 'mysql', 'postgres'), default='sqlite')
args = parser.parse_args()
if args.interruption and (not args.migration or args.database_type != 'sqlite'):
parser.error('中断验收必须选择 SQLite 迁移独立场景')
if args.migration and (args.nginx_template or args.restore):
parser.error('迁移进程验收仅使用直接 TLS 的独立场景')
if args.restore and args.database_type != 'sqlite':
@ -131,6 +134,10 @@ def main():
shutil.copy2(Path(__file__).with_name('runtime_sql_migration.py'), target / 'runtime_sql_migration.py')
(target / 'runtime_sql_migration.py').chmod(0o640)
shutil.chown(target / 'runtime_sql_migration.py', user='root', group='smail')
if args.interruption:
shutil.copy2(Path(__file__).with_name('runtime_interruption.py'), target / 'runtime_interruption.py')
(target / 'runtime_interruption.py').chmod(0o640)
shutil.chown(target / 'runtime_interruption.py', user='root', group='smail')
if args.nginx_template:
shutil.copy2(Path(__file__).with_name('runtime_proxy.py'), target / 'runtime_proxy.py')
(target / 'runtime_proxy.py').chmod(0o640)
@ -267,6 +274,9 @@ def main():
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 0
result['live_message_absent'] = True
if args.migration:
if args.interruption:
from runtime_interruption import verify_interruption
verify_interruption(root, unit, password, result)
if migration_target:
from runtime_sql_migration import verify_migration
verify_migration(root, unit, password, result, sql_fixture, migration_target)
@ -287,7 +297,7 @@ def main():
result['integrity'] = migration_target.integrity(root)
elif args.migration:
from runtime_migration import migration_integrity
result['integrity'] = migration_integrity(root)
result['integrity'] = migration_integrity(root, interrupted=args.interruption)
else:
result['integrity'] = sql_fixture.integrity(root) if sql_fixture else database_integrity(root)
except (AssertionError, sqlite3.Error, RuntimeError, OSError, ValueError, subprocess.SubprocessError) as error: