This commit is contained in:
parent
d4f1b0ccb6
commit
586f2bca19
70
docs/SQLite跨引擎迁移补充验收.md
Normal file
70
docs/SQLite跨引擎迁移补充验收.md
Normal file
@ -0,0 +1,70 @@
|
||||
# SQLite 跨引擎迁移补充验收
|
||||
|
||||
## 范围与任务状态
|
||||
|
||||
本记录补充 SQLite 与 MySQL、PostgreSQL 之间的四个应用级迁移方向。它不替代真实浏览器、外部邮件最终收件、凭据轮换、证书自动续期及全项目生产评审。
|
||||
|
||||
- [✓] 已核对修改前基线 `d4f1b0c` 与远端一致,用户原报告保持不变。
|
||||
- [✓] 混合引擎验收工具与反例测试已补齐。
|
||||
- [✓] SQLite → MySQL、SQLite → PostgreSQL、MySQL → SQLite 三个方向实机通过。
|
||||
- [!] PostgreSQL → SQLite 两次均在初始化恢复码生成请求上超时,尚未进入迁移,不能放行。
|
||||
- [✓] 独立复核文件摘要、账号权限、测试单元及正式服务保护状态。
|
||||
- [✓] 最终 Linux 部署回归 149 项通过、零跳过;本地运行时回归 79 项通过。
|
||||
|
||||
**结论:本轮只关闭三个迁移方向,不能宣称六个跨引擎方向全部通过,更不能宣称全项目达到生产部署级别。**
|
||||
|
||||
## 候选与隔离边界
|
||||
|
||||
本轮仅扩展验收工具,应用后端、前端和插件源码均未修改。使用上轮已编译候选 `smail-character`,版本 `review-character-20260921`,SHA256 为 `97ad202b67e57e6284d7707928cf4591a5d2d676344d7b67f003630242e28924`。启动前逐项校验 611 个后端 Go 源码摘要;不把复用候选描述为本轮全资产重新构建。
|
||||
|
||||
每轮使用新建空实例、随机 SQL 库和专用账号。测试域名、随机管理员口令及自签测试证书只服务于隔离协议探针,不构成域名、正式证书或管理员交互验收。实例通过独立网络、受限 systemd 单元及 192 MiB 应用内存上限运行,不停止正式 Smail、Nginx、MySQL、PostgreSQL、Scert、FDM,不清空正式库。
|
||||
|
||||
SQLite → SQL 仅保留已停用的 `smail.db` 作为离线源证据;SQL → SQLite 只允许活动文件 `migrated.db`。业务表与配置历史都使用同一目标数据库,不能生成第二个状态库。离线证据不是第二个活动数据库,也不代表正式部署必须保留旧库。
|
||||
|
||||
## 工具与断言
|
||||
|
||||
- `test/tools/runtime_mixed_migration.py`:只接受一端 SQLite、一端 SQL;SQLite 文件名固定、路径受限、只读查询且缺库不能新建。
|
||||
- 使用正式迁移预览、执行和系统重启 API,不直接改写配置模拟成功。待重启拒绝写入,内部重启继续使用原会话。
|
||||
- 内部重启、真实系统重启、故障恢复后三次分别检查 SQLite 文件描述符和 SQL 连接;要求源停止使用、目标承担活动读写。
|
||||
- 目标 SQL 账号锁定或 SQLite 文件权限收紧后,应用应明确启动失败;配置保持指向目标,不静默回退源库。异常路径也恢复账号或权限。
|
||||
- 迁移前邮件与各阶段新邮件通过 SMTP、IMAP、POP3 检查中文正文和附件;新邮件只进入目标库。
|
||||
- 配置历史比较四列原始字节并校验载荷摘要;源库逻辑指纹在后续写入、重启和故障注入期间保持一致。
|
||||
- 扫描实例中 SQLite 扩展名、文件头、侧文件和符号链接,拒绝隐藏库、改名库、嵌套额外库。
|
||||
|
||||
## 当前实测记录
|
||||
|
||||
下表实例目录均位于 `/var/tmp/smail-review-runtime-` 前缀下。
|
||||
|
||||
| 方向 | 实例后缀 | 内部重启 PID | 系统重启 PID | 故障恢复 PID | 结果 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| SQLite → MySQL | `20260921-074846` | `790031`,不变 | `790465` | `790531` | 通过 |
|
||||
| SQLite → PostgreSQL | `20260921-075415` | `790691`,不变 | `790937` | `791027` | 通过 |
|
||||
| MySQL → SQLite | `20260921-075919` | `791137`,不变 | `791541` | `791650` | 通过 |
|
||||
| PostgreSQL → SQLite | `20260921-080502` | 未执行 | 未执行 | 未执行 | 初始化请求超时 |
|
||||
| PostgreSQL → SQLite,原条件复测 | `20260921-080854` | 未执行 | 未执行 | 未执行 | 相同初始化请求超时 |
|
||||
|
||||
两个 SQLite → SQL 方向在内部重启、系统重启、故障恢复后的 SQL 连接数均为 `3 / 2 / 2`,旧 SQLite 均未被进程打开。MySQL → SQLite 三阶段源 SQL 连接数均为零,活动 SQLite 均已打开,运行目录没有 `smail.db` 或第二个状态库。
|
||||
|
||||
三个成功方向均保留同一条源配置历史的原始字节与摘要;待重启写入拒绝、旧会话恢复、旧邮件和各阶段新邮件的正文及附件均通过。目标不可用时 PID 为零、服务状态为 failed、退出码为 1,目标配置保留且源库逻辑指纹不变。恢复后读写正常。五轮均无 OOM、资源采集错误或清理错误。
|
||||
|
||||
独立复核覆盖三个成功实例及两个失败实例,共 47 份云端/本地证据摘要、七个运行工具和 611 个后端源码摘要。五个 SQL 专用账号均锁定;三个 PostgreSQL 账号均不能登录、不能建库、不是超级用户。测试应用、探针、桥接和网络锚点全部停止;正式六服务的 PID、InvocationID、重启次数及正式程序摘要均未改变。
|
||||
|
||||
复核索引为 `mixed-recheck-20260921-081310.json`,同时保留原始首轮与复测汇总,不以仅包含成功样本的新文件替代失败记录。
|
||||
|
||||
## 回归与保留边界
|
||||
|
||||
新增十三项反例测试,覆盖路径、缺库不创建、只读连接、历史损坏、隐藏数据库、源连接残留、单活动文件以及故障恢复。初次本地测试发现测试夹具未关闭 SQLite 连接,Windows 临时目录清理失败;修复夹具显式关闭后通过,不将其描述为产品缺陷。
|
||||
|
||||
本地运行时最终回归 79 项通过,日志 `mixed-local-runtime-final.log`。第一次 Linux 全部署回归 148 项通过、零跳过,证据 `python-linux-result-20260921-074736.json`;随后增强隐藏库反例的相互隔离并增加单活动文件反例,最终 Linux 回归实际执行 149 项、零跳过、退出零,证据 `python-linux-result-20260921-081152.json`。最终套件运行约 28.64 秒、内存峰值约 46.3 MiB、交换零;这不是应用容量验收。
|
||||
|
||||
### 保留的 PostgreSQL 首次安装超时
|
||||
|
||||
实例 `/var/tmp/smail-review-runtime-20260921-080502/` 在初始恢复码生成请求上触发 30 秒 HTTP 读取超时,尚未开始迁移,不能算作 PostgreSQL → SQLite 的迁移成功或迁移失败反例。该轮应用 CPU 配额为 25%,应用峰值约 105.3 MiB、交换零、OOM 零,约 44.55 秒内消耗 10.296 秒 CPU。CPU 限额可能影响耗时,但这些指标不足以独立确认超时根因。
|
||||
|
||||
该轮正式服务未改变,测试进程、桥接和网络锚点已停止,PostgreSQL 专用账号已锁定。失败证据保留在 `mixed-runtime-evidence/smail-review-runtime-20260921-080502/`,原始汇总为 `mixed-results-20260921-074833.json`。
|
||||
|
||||
第二轮 `080854` 保持同一候选、资源限制和超时断言,新建空库与账号,仍在同一请求发生读取超时。结果为 `mixed-results-20260921-080840.json`,对应原始证据目录同样保留。收尾完整性检查因目标尚未创建而报告断言失败,不能误述成目标 SQLite 已损坏。没有继续盲目重试,也没有放宽限制或将失败从汇总中移除。
|
||||
|
||||
源码 `backend/services/credentials/service.go` 的该路径逐个生成十枚恢复码,并串行调用 `password.Hash` 后写入事务。后续需要分别采集密码计算、数据库事务和请求截止时间的证据,才能确定超时原因与整改方案;不能只凭当前日志将其归为 PostgreSQL 迁移缺陷,也不能降低密码哈希强度来获取通过结果。
|
||||
|
||||
本地证据根目录为 `test-log/production-remediation-20260921/`。成功与失败证据均保留,不删除源库、失败目标或用户文件。
|
||||
@ -89,3 +89,9 @@
|
||||
## 后续 SQLite 复制中断补充
|
||||
|
||||
9 月 21 日 06:17 的新空隔离实例完成未提交复制事务中的 SIGKILL 中断及恢复,随后正常迁移和重启恢复也通过。详见 `SQLite复制中断恢复补充验收.md`,包含两个夹具失败记录、原始目标及 journal 快照摘要、20 份证据复核与 131 项部署回归。本项仅关闭 SQLite 未提交复制阶段,不关闭提交临界点、配置切换临界点、SQL 数据库中断或跨引擎矩阵。
|
||||
|
||||
## 后续混合引擎矩阵补充
|
||||
|
||||
详见 `SQLite跨引擎迁移补充验收.md`。SQLite → MySQL、SQLite → PostgreSQL、MySQL → SQLite 三个方向通过正式迁移、两类重启、目标不可用及恢复后的读写,三阶段证明只有目标库活动,配置历史同库。
|
||||
|
||||
PostgreSQL → SQLite 两次均在空安装恢复码生成请求的 30 秒读取超时处终止,尚未进入迁移;失败原样保留,不能宣称矩阵完整。47 份成功/失败证据及全部测试账号、测试单元和正式服务保护状态已独立复核。最终部署回归 149 项、零跳过,但不关闭初始化超时、迁移中断临界点或其他生产门禁。
|
||||
|
||||
@ -331,3 +331,11 @@ SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系
|
||||
新候选完成 MySQL ↔ PostgreSQL 双向迁移、内部重启、系统级重启、目标账号故障及恢复后的真实三协议读写,源库零活动连接、源内容不变、配置历史同库、没有 SQLite 文件。26 份实机证据、六个工具及 611 个后端源码摘要再次核对一致,测试账号锁定且临时建库权限撤销,正式六服务和程序未变。
|
||||
|
||||
云端完整部署回归实际为 136 项、零跳过;新增 Go 专项两个顶层测试零跳过。本地全量后端通过,但明确保留 35 个顶层和 12 个子测试跳过,不能称全平台零跳过。涉及 SQLite 的其余四个跨引擎应用级方向、中断临界点和此前其他生产门禁仍未关闭,整体生产验收尚未通过。
|
||||
|
||||
### 9 月 21 日 SQLite 跨引擎补充
|
||||
|
||||
详见 `SQLite跨引擎迁移补充验收.md`。沿用源码摘要一致的最新编译候选,只扩展验收工具;SQLite → MySQL、SQLite → PostgreSQL、MySQL → SQLite 三个方向通过。两个 SQLite 源均停止占用,MySQL 源活动连接归零;目标独立承载业务和配置历史。两类重启、目标故障拒绝启动与恢复、原会话、历史邮件及新增邮件正文和附件均通过。
|
||||
|
||||
PostgreSQL → SQLite 两次在初始化恢复码生成时发生 30 秒 HTTP 读取超时,未进入迁移。没有降低哈希强度、减少码数量或放宽超时条件,不将失败标为通过。该初始化耗时问题和最后一个迁移方向继续待查。
|
||||
|
||||
最终本地运行时回归 79 项、Linux 完整部署回归 149 项通过,Linux 零跳过;47 份成功与失败证据、七个工具及 611 个后端源码摘要已独立复核。五个 SQL 测试账号锁定,测试单元全部停止,正式六服务和程序未变;用户原测试报告未编辑。全项目生产验收仍未通过,历史凭据、其余安全告警、真实浏览器、外部投递、自动续期、容量及异机灾备等范围不能由本轮结果代替。
|
||||
|
||||
165
test/deployment/test_runtime_mixed_migration.py
Normal file
165
test/deployment/test_runtime_mixed_migration.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""混合引擎验收器反例测试;本地临时库不连接云端。"""
|
||||
|
||||
import hashlib
|
||||
from contextlib import closing
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
|
||||
import runtime_mixed_migration as mixed
|
||||
from runtime_sql import SQLFixture
|
||||
|
||||
|
||||
class RuntimeMixedMigrationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name).resolve()
|
||||
(self.root / 'data').mkdir()
|
||||
(self.root / 'config').mkdir()
|
||||
self.source = mixed.SQLiteDatabase(self.root, {'kind': 'sqlite', 'name': 'smail.db'})
|
||||
self.target = SQLFixture('mysql')
|
||||
with closing(sqlite3.connect(self.source.path)) as database:
|
||||
database.execute('CREATE TABLE config_records(kind TEXT,id TEXT,payload BLOB,digest TEXT)')
|
||||
payload = '中文\x00载荷'.encode()
|
||||
database.execute('INSERT INTO config_records VALUES (?,?,?,?)',
|
||||
('backup', 'item', payload, hashlib.sha256(payload).hexdigest()))
|
||||
database.commit()
|
||||
self.configure(self.target)
|
||||
|
||||
def configure(self, target):
|
||||
(self.root / 'config/config.json').write_text(json.dumps({'database': mixed.target_payload(target)}))
|
||||
|
||||
def test_only_fixed_local_names_are_allowed(self):
|
||||
for name in ('../smail.db', '/smail.db', 'nested/smail.db', 'state.db', ''):
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.SQLiteDatabase(self.root, {'kind': 'sqlite', 'name': name})
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.SQLiteDatabase(self.root, {'kind': 'sqlite', 'name': 'smail.db', 'password': 'extra'})
|
||||
|
||||
def test_missing_database_is_not_created(self):
|
||||
missing = mixed.SQLiteDatabase(self.root, {'kind': 'sqlite', 'name': 'migrated.db'})
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
missing.query('SELECT 1')
|
||||
self.assertFalse(missing.path.exists())
|
||||
|
||||
def test_read_only_query_cannot_change_database(self):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
self.source.query('DELETE FROM config_records')
|
||||
self.assertEqual(self.source.query('SELECT COUNT(*) FROM config_records'), '1')
|
||||
|
||||
def test_history_keeps_bytes_and_rejects_null_or_bad_digest(self):
|
||||
values = mixed.history_rows(self.source)
|
||||
self.assertEqual(bytes.fromhex(json.loads(values[0])[2]), '中文\x00载荷'.encode())
|
||||
for digest in (None, 'invalid'):
|
||||
with closing(sqlite3.connect(self.source.path)) as database:
|
||||
database.execute('UPDATE config_records SET digest=?', (digest,))
|
||||
database.commit()
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.history_rows(self.source)
|
||||
|
||||
def test_expected_offline_source_and_wrong_configuration(self):
|
||||
mixed.assert_active(self.root, self.source, self.target)
|
||||
self.configure(self.source)
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.assert_active(self.root, self.source, self.target)
|
||||
|
||||
def test_sql_to_sqlite_has_only_one_active_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
(root / 'data').mkdir()
|
||||
(root / 'config').mkdir()
|
||||
target = mixed.SQLiteDatabase(root, {'kind': 'sqlite', 'name': 'migrated.db'})
|
||||
shutil.copyfile(self.source.path, target.path)
|
||||
(root / 'config/config.json').write_text(json.dumps({'database': mixed.target_payload(target)}))
|
||||
result = mixed.migration_integrity(root, self.target, target)
|
||||
self.assertEqual(result['active_database'], 'migrated.db')
|
||||
self.assertEqual(result['sqlite_files'], 1)
|
||||
self.assertEqual(result['retained_offline_files'], [])
|
||||
shutil.copyfile(target.path, root / 'data/smail.db')
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.migration_integrity(root, self.target, target)
|
||||
|
||||
def test_hidden_nested_and_renamed_databases_rejected(self):
|
||||
for name in ('state.db', '.hidden.sqlite3', 'nested/smail.db', 'renamed.bin'):
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
(root / 'data').mkdir()
|
||||
shutil.copyfile(self.source.path, root / 'data/smail.db')
|
||||
source = mixed.SQLiteDatabase(root, self.source.settings)
|
||||
mixed.assert_files(root, source, self.target)
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(sqlite3.connect(path)) as database:
|
||||
database.execute('CREATE TABLE marker(id INTEGER)')
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.assert_files(root, source, self.target)
|
||||
|
||||
def test_symlink_is_rejected_before_open(self):
|
||||
with patch.object(Path, 'is_symlink', return_value=True):
|
||||
with self.assertRaises(AssertionError):
|
||||
self.source.query('SELECT 1')
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.assert_files(self.root, self.source, self.target)
|
||||
|
||||
def test_sql_target_requires_source_closed_and_target_connected(self):
|
||||
for names, count, accepted in ((set(), 3, True), ({'/opt/smail/data/smail.db'}, 3, False),
|
||||
({'/opt/smail/data/smail.db-wal'}, 3, False),
|
||||
({'/opt/smail/data/smail.db (deleted)'}, 3, False), (set(), 0, False)):
|
||||
with patch.object(mixed, 'descriptor_names', return_value=names), \
|
||||
patch.object(mixed, 'connection_count', return_value=count):
|
||||
if accepted:
|
||||
self.assertTrue(mixed.assert_connections(self.root, {'MainPID': '123'}, self.source, self.target)['source_inactive'])
|
||||
else:
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.assert_connections(self.root, {'MainPID': '123'}, self.source, self.target)
|
||||
|
||||
def test_sql_source_requires_zero_connections_and_sqlite_open(self):
|
||||
with patch.object(mixed, 'assert_active'):
|
||||
for names, count, accepted in (({'/opt/smail/data/smail.db'}, 0, True),
|
||||
({'/opt/smail/data/smail.db'}, 1, False), (set(), 0, False)):
|
||||
with patch.object(mixed, 'descriptor_names', return_value=names), \
|
||||
patch.object(mixed, 'connection_count', return_value=count):
|
||||
if accepted:
|
||||
self.assertTrue(mixed.assert_connections(self.root, {'MainPID': '123'}, self.target, self.source)['target_active'])
|
||||
else:
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.assert_connections(self.root, {'MainPID': '123'}, self.target, self.source)
|
||||
|
||||
def test_sql_account_restored_even_when_lock_or_body_fails(self):
|
||||
for lock_failure in (False, True):
|
||||
target = Mock(kind='mysql')
|
||||
if lock_failure:
|
||||
target.lock.side_effect = RuntimeError('lock failure')
|
||||
with self.assertRaises(RuntimeError):
|
||||
with mixed.unavailable_target(target):
|
||||
raise RuntimeError('probe failure')
|
||||
target.unlock.assert_called_once_with()
|
||||
|
||||
def test_sqlite_permissions_restored_on_failure(self):
|
||||
with patch.object(Path, 'stat', return_value=Mock(st_mode=stat.S_IFREG | 0o600)), \
|
||||
patch.object(Path, 'is_file', return_value=True), \
|
||||
patch.object(mixed.SQLiteDatabase, 'path', new_callable=PropertyMock,
|
||||
return_value=self.root / 'data/smail.db'), \
|
||||
patch.object(Path, 'chmod') as chmod:
|
||||
with self.assertRaises(RuntimeError):
|
||||
with mixed.unavailable_target(self.source):
|
||||
raise RuntimeError('probe failure')
|
||||
self.assertEqual([call.args[0] for call in chmod.call_args_list], [0, 0o600])
|
||||
|
||||
def test_formal_root_rejected_before_service_calls(self):
|
||||
with patch.object(mixed, 'service_state') as state:
|
||||
with self.assertRaises(AssertionError):
|
||||
mixed.verify_migration(Path('/opt/smail'), 'smail.service', '', {}, self.source, self.target)
|
||||
state.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -18,8 +18,10 @@ class RuntimeSQLMigrationTest(unittest.TestCase):
|
||||
for source, target in (('mysql', 'postgres'), ('postgres', 'mysql')):
|
||||
self.assertEqual(review.migration_kind(source, target, True, False), target)
|
||||
self.assertEqual(review.migration_kind('sqlite', None, True, False), 'sqlite')
|
||||
for arguments in (('sqlite', 'mysql', True, False), ('mysql', 'postgres', False, False),
|
||||
('mysql', 'postgres', True, True), ('mysql', 'sqlite', True, False)):
|
||||
for source, target in (('sqlite', 'mysql'), ('sqlite', 'postgres'), ('mysql', 'sqlite'), ('postgres', 'sqlite')):
|
||||
self.assertEqual(review.migration_kind(source, target, True, False), target)
|
||||
for arguments in (('mysql', 'postgres', False, False), ('mysql', 'postgres', True, True),
|
||||
('sqlite', 'mysql', True, True), ('mysql', 'invalid', True, False)):
|
||||
with self.assertRaises(ValueError):
|
||||
review.migration_kind(*arguments)
|
||||
|
||||
|
||||
@ -205,7 +205,8 @@ def run_probe(root, unit, password, phase, sql_settings=None, probe_script=None)
|
||||
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
|
||||
assert ((probe_script == 'runtime_interruption.py' and sql_settings is None)
|
||||
or (probe_script == 'runtime_mixed_migration.py' and sql_settings is not None))
|
||||
script = probe_script
|
||||
command = ['systemd-run', '--wait', '--pipe', '--unit=' + probe,
|
||||
'--property=User=smail', '--property=Group=smail',
|
||||
|
||||
232
test/tools/runtime_mixed_migration.py
Normal file
232
test/tools/runtime_mixed_migration.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""SQLite 与服务器数据库之间的隔离应用级验收,不操作正式库。"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from runtime_migration import database_fingerprint, read_database, run_probe, service_state
|
||||
from runtime_sql import SQLDatabase
|
||||
from runtime_sql_migration import connection_count, fingerprint, history_rows, prepare, target_payload, verify_mail
|
||||
|
||||
|
||||
class SQLiteDatabase:
|
||||
def __init__(self, root, settings):
|
||||
assert set(settings) == {'kind', 'name'} and settings['kind'] == 'sqlite'
|
||||
assert settings['name'] in ('smail.db', 'migrated.db')
|
||||
self.root = root
|
||||
self.settings = settings.copy()
|
||||
self.kind = 'sqlite'
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
path = self.root / 'data' / self.settings['name']
|
||||
assert self.root.is_absolute() and self.root.resolve(strict=True) == self.root
|
||||
assert not self.root.is_symlink() and not path.parent.is_symlink() and not path.is_symlink()
|
||||
assert path.resolve() == path
|
||||
return path
|
||||
|
||||
def query(self, sql):
|
||||
with read_database(self.path) as database:
|
||||
rows = database.execute(sql).fetchall()
|
||||
assert all(len(row) == 1 and row[0] is not None for row in rows)
|
||||
return '\n'.join(str(row[0]) for row in rows)
|
||||
|
||||
def verify_message(self, message_id, account, body, attachment):
|
||||
with read_database(self.path) as database:
|
||||
rows = database.execute('SELECT id,text,attachments FROM email WHERE msg_id=?', (message_id,)).fetchall()
|
||||
assert len(rows) == 1
|
||||
key, text, attachments = rows[0]
|
||||
assert body in text
|
||||
values = json.loads(attachments)
|
||||
assert len(values) == 1 and values[0]['Filename'] == '验收附件.txt'
|
||||
assert base64.b64decode(values[0]['Content'], validate=True) == attachment
|
||||
assert database.execute('SELECT u.account FROM user_email e JOIN "user" u ON u.id=e.user_id '
|
||||
'WHERE e.email_id=?', (key,)).fetchall() == [(account,)]
|
||||
|
||||
|
||||
def assert_files(root, source, target):
|
||||
expected = {root / 'data' / database.settings['name'] for database in (source, target)
|
||||
if database.kind == 'sqlite'}
|
||||
seen = set()
|
||||
# 不只按扩展名找库:同时识别 SQLite 文件头,拒绝嵌套、改名和符号链接。
|
||||
for path in root.rglob('*'):
|
||||
assert not path.is_symlink(), '隔离实例不能通过符号链接隐藏第二个库'
|
||||
if not path.is_file():
|
||||
continue
|
||||
with path.open('rb') as stream:
|
||||
header = stream.read(16)
|
||||
if header == b'SQLite format 3\x00' or path.suffix == '.db' or '.sqlite' in path.name:
|
||||
assert path in expected, '发现非本轮来源或目标的 SQLite 文件'
|
||||
seen.add(path)
|
||||
if path.name.endswith(('-wal', '-shm', '-journal')):
|
||||
assert path.with_name(path.name.rsplit('-', 1)[0]) in expected
|
||||
assert seen == expected, '本轮 SQLite 证据文件缺失'
|
||||
return sorted(path.name for path in seen)
|
||||
|
||||
|
||||
def assert_configuration(root, target):
|
||||
database = json.loads((root / 'config/config.json').read_text())['database']
|
||||
expected = target_payload(target)
|
||||
for field in ('type', 'localRef') if target.kind == 'sqlite' else ('type', 'host', 'port', 'username', 'name'):
|
||||
assert database.get(field) == expected[field], '活动配置没有指向本轮唯一目标'
|
||||
|
||||
|
||||
def assert_active(root, source, target):
|
||||
assert_configuration(root, target)
|
||||
assert_files(root, source, target)
|
||||
|
||||
|
||||
def descriptor_names(pid):
|
||||
assert pid.isdigit() and int(pid) > 0
|
||||
names = set()
|
||||
for descriptor in Path('/proc', pid, 'fd').iterdir():
|
||||
try:
|
||||
names.add(str(descriptor.readlink()))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def assert_connections(root, state, source, target):
|
||||
names = descriptor_names(state['MainPID'])
|
||||
database = target if target.kind != 'sqlite' else source
|
||||
count = connection_count(database)
|
||||
sqlite = source if source.kind == 'sqlite' else target
|
||||
prefix = '/opt/smail/data/' + sqlite.settings['name']
|
||||
opened = any(name == prefix or name.startswith(prefix + '-') or name.startswith(prefix + ' (deleted)')
|
||||
for name in names)
|
||||
if source.kind == 'sqlite':
|
||||
assert not opened and count > 0, 'SQLite 源仍被打开或 SQL 目标无连接'
|
||||
else:
|
||||
assert prefix in names and count == 0, 'SQLite 目标未打开或 SQL 源仍有连接'
|
||||
assert_active(root, source, target)
|
||||
return {'sql_connections': count, 'sqlite_open': opened, 'source_inactive': True, 'target_active': True}
|
||||
|
||||
|
||||
def source_fingerprint(database):
|
||||
return database_fingerprint(database.path) if database.kind == 'sqlite' else fingerprint(database)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def unavailable_target(target):
|
||||
if target.kind == 'sqlite':
|
||||
path = target.path
|
||||
assert path.is_file() and stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
try:
|
||||
path.chmod(0)
|
||||
yield
|
||||
finally:
|
||||
path.chmod(0o600)
|
||||
else:
|
||||
try:
|
||||
target.lock()
|
||||
yield
|
||||
finally:
|
||||
target.unlock()
|
||||
|
||||
|
||||
def migration_integrity(root, source, target):
|
||||
assert_active(root, source, target)
|
||||
sqlite = source if source.kind == 'sqlite' else target
|
||||
with read_database(sqlite.path) as database:
|
||||
assert database.execute('PRAGMA integrity_check').fetchone() == ('ok',)
|
||||
count = int(target.query('SELECT COUNT(*) FROM config_records'))
|
||||
assert count > 0
|
||||
return {'type': target.kind, 'active_database': target.settings['name'], 'config_records': count,
|
||||
'sqlite_files': 1, 'retained_offline_files': [source.settings['name']] if source.kind == 'sqlite' else [],
|
||||
'integrity': 'ok'}
|
||||
|
||||
|
||||
def verify_migration(root, unit, password, result, source, target):
|
||||
assert root.parent.parent == Path('/var/tmp') and re.fullmatch(r'smail-review-runtime-\d{8}-\d{6}', root.parent.name)
|
||||
assert root.name == 'instance' and not root.is_symlink() and root.resolve(strict=True) == root
|
||||
assert source.kind != target.kind and 'sqlite' in (source.kind, target.kind)
|
||||
assert unit == 'smail-runtime-review-' + root.parent.name.removeprefix('smail-review-runtime-')
|
||||
details = result['migration'] = {'passed': False, 'database_type': source.kind, 'target_type': target.kind,
|
||||
'cross_engine': True, 'source_database': source.settings['name'],
|
||||
'target_database': target.settings['name']}
|
||||
settings = {'source': source.settings, 'target': target.settings}
|
||||
|
||||
def probe(phase):
|
||||
return run_probe(root, unit, password, phase, settings, 'runtime_mixed_migration.py')
|
||||
|
||||
before = service_state(unit)
|
||||
details['prepare'] = probe('prepare')
|
||||
if target.kind != 'sqlite':
|
||||
target.revoke_creation()
|
||||
details['temporary_creation_privilege_revoked'] = True
|
||||
internal = service_state(unit)
|
||||
assert before['MainPID'] == internal['MainPID'] and before['InvocationID'] == internal['InvocationID']
|
||||
details['internal_restart_same_process'] = True
|
||||
details['connections_after_internal'] = assert_connections(root, internal, source, target)
|
||||
digest = source_fingerprint(source)
|
||||
history = history_rows(source)
|
||||
assert history
|
||||
details['internal'] = probe('internal')
|
||||
subprocess.run(['systemctl', 'restart', unit], check=True, timeout=55)
|
||||
restarted = service_state(unit)
|
||||
assert restarted['MainPID'] != internal['MainPID'] and restarted['InvocationID'] != internal['InvocationID']
|
||||
details['process'] = probe('process')
|
||||
details['connections_after_process'] = assert_connections(root, restarted, source, target)
|
||||
details['process_states'] = {'before': before, 'internal': internal, 'restarted': restarted}
|
||||
with unavailable_target(target):
|
||||
subprocess.run(['systemctl', 'restart', unit], check=True, timeout=55)
|
||||
for _ in range(70):
|
||||
failed = service_state(unit)
|
||||
if failed['MainPID'] == '0' and failed['ActiveState'] == 'failed':
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise AssertionError('目标不可用时应用没有明确停止,不能接受静默回退')
|
||||
assert_configuration(root, target)
|
||||
assert source_fingerprint(source) == digest
|
||||
details['unavailable_target'] = {'failed_closed': True, 'source_unchanged': True,
|
||||
'configuration_preserved': True, 'state': failed}
|
||||
subprocess.run(['systemctl', 'start', unit], check=True, timeout=55)
|
||||
details['recovered'] = probe('recovered')
|
||||
final = service_state(unit)
|
||||
assert final['MainPID'] not in (before['MainPID'], restarted['MainPID'])
|
||||
details['connections_after_recovery'] = assert_connections(root, final, source, target)
|
||||
assert source_fingerprint(source) == digest
|
||||
current = history_rows(target)
|
||||
assert set(history).issubset(current)
|
||||
details['history'] = {'source_records': len(history), 'target_records': len(current), 'all_source_records_equal': True}
|
||||
details.update(source_unchanged=True, target_access_restored=True, final_state=final, passed=True)
|
||||
(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=('prepare', 'internal', 'process', 'recovered'))
|
||||
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 = (SQLiteDatabase(args.root, settings[key]) if settings[key]['kind'] == 'sqlite'
|
||||
else SQLDatabase(settings[key]) for key in ('source', 'target'))
|
||||
assert source.kind != target.kind and 'sqlite' in (source.kind, target.kind)
|
||||
|
||||
def active_check(root, database):
|
||||
assert_active(root, source, database)
|
||||
|
||||
result = (prepare(args.root, password, source, target, active_check) if args.phase == 'prepare' else
|
||||
verify_mail(args.root, password, args.phase, source, target, active_check))
|
||||
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, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -75,7 +75,7 @@ def collect_and_stop(result, unit, probe_unit, proxy_unit=None):
|
||||
def migration_kind(source, target, enabled, interrupted):
|
||||
if target is None:
|
||||
return source
|
||||
if not enabled or interrupted or source == 'sqlite' or target not in ('mysql', 'postgres'):
|
||||
if not enabled or interrupted or target not in ('sqlite', 'mysql', 'postgres'):
|
||||
raise ValueError('跨引擎目标必须用于独立 SQL 迁移验收')
|
||||
return target
|
||||
|
||||
@ -103,7 +103,7 @@ def main():
|
||||
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')
|
||||
parser.add_argument('--migration-target-type', choices=('mysql', 'postgres'))
|
||||
parser.add_argument('--migration-target-type', choices=('sqlite', 'mysql', 'postgres'))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
target_kind = migration_kind(args.database_type, args.migration_target_type, args.migration, args.interruption)
|
||||
@ -152,7 +152,8 @@ def main():
|
||||
shutil.copy2(Path(__file__).with_name('runtime_migration.py'), target / 'runtime_migration.py')
|
||||
(target / 'runtime_migration.py').chmod(0o640)
|
||||
shutil.chown(target / 'runtime_migration.py', user='root', group='smail')
|
||||
if args.database_type != 'sqlite':
|
||||
mixed = args.migration and args.database_type != target_kind and 'sqlite' in (args.database_type, target_kind)
|
||||
if args.database_type != 'sqlite' or mixed:
|
||||
shutil.copy2(Path(__file__).with_name('runtime_sql.py'), target / 'runtime_sql.py')
|
||||
(target / 'runtime_sql.py').chmod(0o640)
|
||||
shutil.chown(target / 'runtime_sql.py', user='root', group='smail')
|
||||
@ -160,6 +161,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 mixed:
|
||||
shutil.copy2(Path(__file__).with_name('runtime_mixed_migration.py'), target / 'runtime_mixed_migration.py')
|
||||
(target / 'runtime_mixed_migration.py').chmod(0o640)
|
||||
shutil.chown(target / 'runtime_mixed_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)
|
||||
@ -197,11 +202,17 @@ def main():
|
||||
from runtime_sql import SQLFixture
|
||||
sql_fixture = SQLFixture(args.database_type)
|
||||
result['sql_database'] = sql_fixture.settings['name']
|
||||
if args.migration:
|
||||
from runtime_sql_migration import MigrationTarget
|
||||
migration_target = MigrationTarget(target_kind)
|
||||
result['sql_target_database'] = migration_target.settings['name']
|
||||
result['migration_target_type'] = target_kind
|
||||
if args.migration and target_kind != 'sqlite':
|
||||
from runtime_sql_migration import MigrationTarget
|
||||
migration_target = MigrationTarget(target_kind)
|
||||
result['sql_target_database'] = migration_target.settings['name']
|
||||
if args.migration:
|
||||
result['migration_target_type'] = target_kind
|
||||
mixed_source = mixed_target = None
|
||||
if mixed:
|
||||
from runtime_mixed_migration import SQLiteDatabase
|
||||
mixed_source = sql_fixture or SQLiteDatabase(root, {'kind': 'sqlite', 'name': 'smail.db'})
|
||||
mixed_target = migration_target or SQLiteDatabase(root, {'kind': 'sqlite', 'name': 'migrated.db'})
|
||||
if args.nginx_template:
|
||||
from runtime_proxy import prepare_nginx
|
||||
prepare_nginx(target, root, args.nginx_template)
|
||||
@ -218,6 +229,7 @@ def main():
|
||||
network_args = ['--property=PrivateNetwork=yes']
|
||||
if migration_target:
|
||||
migration_target.create()
|
||||
if args.migration and (sql_fixture or migration_target):
|
||||
# 使用独立限时单元固定隔离网络,应用换进程后仍能访问本轮数据库桥接。
|
||||
network_unit = 'smail-runtime-netns-' + stamp
|
||||
subprocess.run([
|
||||
@ -255,11 +267,9 @@ def main():
|
||||
assert str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net
|
||||
assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino
|
||||
assert (root / 'smail').stat().st_ino != (live / 'smail').stat().st_ino
|
||||
if sql_fixture:
|
||||
if sql_fixture or migration_target:
|
||||
from runtime_sql_bridge import DatabaseBridge
|
||||
ports = {sql_fixture.port}
|
||||
if migration_target:
|
||||
ports.add(migration_target.port)
|
||||
ports = {fixture.port for fixture in (sql_fixture, migration_target) if fixture}
|
||||
for port in sorted(ports):
|
||||
bridges[port] = DatabaseBridge(int(pid), port)
|
||||
result['database_bridge_ports'] = sorted(bridges)
|
||||
@ -309,7 +319,10 @@ def main():
|
||||
if args.interruption:
|
||||
from runtime_interruption import verify_interruption
|
||||
verify_interruption(root, unit, password, result)
|
||||
if migration_target:
|
||||
if mixed:
|
||||
from runtime_mixed_migration import verify_migration
|
||||
verify_migration(root, unit, password, result, mixed_source, mixed_target)
|
||||
elif migration_target:
|
||||
from runtime_sql_migration import verify_migration
|
||||
verify_migration(root, unit, password, result, sql_fixture, migration_target)
|
||||
else:
|
||||
@ -323,7 +336,10 @@ def main():
|
||||
result['live_unchanged'] = (result['after'] == before
|
||||
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
|
||||
try:
|
||||
if migration_target:
|
||||
if mixed:
|
||||
from runtime_mixed_migration import migration_integrity
|
||||
result['integrity'] = migration_integrity(root, mixed_source, mixed_target)
|
||||
elif migration_target:
|
||||
from runtime_sql_migration import assert_active
|
||||
assert_active(root, migration_target)
|
||||
result['integrity'] = migration_target.integrity(root)
|
||||
@ -362,7 +378,10 @@ def main():
|
||||
assert result['live_unchanged'] and result['oom_events'] == 0
|
||||
assert not result['cleanup_errors'], '测试单元未全部停止,需要核查保留的结果'
|
||||
assert not result['resource_errors'], '资源证据不完整,需要核查保留的结果'
|
||||
if sql_fixture:
|
||||
if mixed:
|
||||
assert result['migration']['passed'] and result['network_anchor_stopped']
|
||||
assert result['integrity']['config_records'] > 0
|
||||
elif sql_fixture:
|
||||
assert result['integrity'].get('sqlite_files') == 0 and result['integrity']['config_records'] > 0
|
||||
if args.migration:
|
||||
assert result['migration']['passed'] and result['network_anchor_stopped']
|
||||
|
||||
@ -62,6 +62,8 @@ class MigrationTarget(SQLFixture):
|
||||
|
||||
def target_payload(database):
|
||||
settings = database.settings
|
||||
if database.kind == 'sqlite':
|
||||
return {'type': 'sqlite', 'localRef': settings['name']}
|
||||
return {'type': 'mysql' if database.kind == 'mysql' else 'postgresql',
|
||||
'host': '127.0.0.1', 'port': database.port, 'username': settings['user'],
|
||||
'name': settings['name'], 'password': {'plaintext': settings['password']}}
|
||||
@ -104,6 +106,10 @@ def history_rows(database):
|
||||
if database.kind == 'mysql':
|
||||
values = ','.join('LOWER(HEX(`' + column + '`))' for column in ('kind', 'id', 'payload', 'digest'))
|
||||
query = 'SELECT JSON_ARRAY(' + values + ') FROM config_records'
|
||||
elif database.kind == 'sqlite':
|
||||
values = ','.join('CASE WHEN "' + column + '" IS NULL THEN NULL ELSE lower(hex("' + column + '")) END'
|
||||
for column in ('kind', 'id', 'payload', 'digest'))
|
||||
query = 'SELECT json_array(' + values + ') FROM config_records'
|
||||
else:
|
||||
values = ["encode(convert_to(" + column + ",'UTF8'),'hex')" for column in ('kind', 'id')]
|
||||
values += ["encode(payload,'hex')", "encode(convert_to(digest,'UTF8'),'hex')"]
|
||||
@ -120,20 +126,22 @@ def history_rows(database):
|
||||
return sorted(rows)
|
||||
|
||||
|
||||
def connection_count(database):
|
||||
name, user = database.settings['name'], database.settings['user']
|
||||
if database.kind == 'mysql':
|
||||
query = "SELECT COUNT(*) FROM information_schema.processlist WHERE USER='" + user + "' AND DB='" + name + "';"
|
||||
else:
|
||||
query = "SELECT COUNT(*) FROM pg_stat_activity WHERE usename='" + user + "' AND datname='" + name + "';"
|
||||
return int(database.admin(query))
|
||||
|
||||
|
||||
def assert_connections(source, target):
|
||||
counts = []
|
||||
for database in (source, target):
|
||||
name, user = database.settings['name'], database.settings['user']
|
||||
if database.kind == 'mysql':
|
||||
query = "SELECT COUNT(*) FROM information_schema.processlist WHERE USER='" + user + "' AND DB='" + name + "';"
|
||||
else:
|
||||
query = "SELECT COUNT(*) FROM pg_stat_activity WHERE usename='" + user + "' AND datname='" + name + "';"
|
||||
counts.append(int(database.admin(query)))
|
||||
counts = [connection_count(database) for database in (source, target)]
|
||||
assert counts[0] == 0 and counts[1] > 0, '源库仍有活动连接或目标库没有应用连接'
|
||||
return {'source_connections': counts[0], 'target_connections': counts[1]}
|
||||
|
||||
|
||||
def prepare(root, password, source, target):
|
||||
def prepare(root, password, source, target, active_check=assert_active):
|
||||
client, _ = login(root, password)
|
||||
generation = clear_pending_restart(client)
|
||||
endpoint = '/api/v1/database/migrations'
|
||||
@ -142,7 +150,7 @@ def prepare(root, password, source, target):
|
||||
})
|
||||
record = client.success(endpoint, {'id': preview['migration']['id'], 'token': preview['token']})
|
||||
assert record['status'] == 'succeeded'
|
||||
assert_active(root, target)
|
||||
active_check(root, target)
|
||||
pending = client.success('/api/v1/config/status', method='GET')
|
||||
assert pending['activeGeneration'] > generation and pending['pendingRestart']
|
||||
status, denied, _ = client.request('/api/v1/security/credentials/protocols/generate', {'protocol': 'imap'})
|
||||
@ -154,9 +162,9 @@ def prepare(root, password, source, target):
|
||||
'existing_session_preserved': True, 'generation': pending['activeGeneration']}
|
||||
|
||||
|
||||
def verify_mail(root, password, phase, source, target):
|
||||
def verify_mail(root, password, phase, source, target, active_check=assert_active):
|
||||
client, context = login(root, password)
|
||||
assert_active(root, target)
|
||||
active_check(root, target)
|
||||
assert not client.success('/api/v1/config/status', method='GET')['pendingRestart']
|
||||
codes = {}
|
||||
for protocol in ('smtp', 'imap', 'pop3'):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user