This commit is contained in:
parent
2de629fb79
commit
d8b5a5fb23
64
docs/SQLite复制中断恢复补充验收.md
Normal file
64
docs/SQLite复制中断恢复补充验收.md
Normal 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 自动续期与全协议换证、外部邮件最终收件、长时容量和异机灾备。上述门禁不得随本次提交自动改为通过。
|
||||
@ -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 数据库中断或跨引擎矩阵。
|
||||
|
||||
@ -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 中断、跨引擎矩阵和前述其他生产门禁仍未关闭,不能宣称整体验收通过。
|
||||
|
||||
78
test/deployment/test_runtime_interruption.py
Normal file
78
test/deployment/test_runtime_interruption.py
Normal 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()
|
||||
232
test/tools/runtime_interruption.py
Normal file
232
test/tools/runtime_interruption.py
Normal 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()
|
||||
@ -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',
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user