Smail/test/tools/deploy_review.py
smail b71fbc49fe
Some checks are pending
Security Checks / security (push) Waiting to run
完善单数据库识别并对齐统一配置迁移验收
2026-09-22 10:25:53 +08:00

140 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""仅在 47 服务器备份并升级 Smail,保留单库和失败证据。"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
from contextlib import closing
import subprocess
import time
from database_inventory import sqlite_inventory
ROOT = Path('/opt/smail')
STAGING = Path('/var/tmp/smail-review-20260917')
DATABASE_NAME = 'smail.db'
def run(*args):
return subprocess.check_output(args, text=True).strip()
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def database_path(root):
data = root / 'data'
files = sqlite_inventory(root, [data / 'smail.db'])
expected = data / DATABASE_NAME
assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db'
assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接'
return expected
def snapshot(source, target):
with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)) as original:
with closing(sqlite3.connect(target)) as copied:
original.backup(copied)
assert copied.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
target.chmod(0o600)
def database_state(root):
database = database_path(root)
with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as connection:
assert connection.execute('PRAGMA integrity_check').fetchall() == [('ok',)]
version = connection.execute('SELECT MAX(version) FROM schema_migration').fetchone()[0]
assert version == 24
connection.execute('SELECT COUNT(*) FROM config_records').fetchone()
states = dict(connection.execute('SELECT state,COUNT(*) FROM outbound_delivery GROUP BY state'))
return version, states
def wait_ready():
for _ in range(60):
result = subprocess.run([
'curl', '--silent', '--fail', '--max-time', '2', '--noproxy', '*',
'--cacert', str(ROOT / 'ssl/custom.crt'), '--resolve',
'web.solit.cn:443:127.0.0.1', 'https://web.solit.cn/api/ping',
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode == 0 and run('systemctl', 'is-active', 'smail.service') == 'active':
return
time.sleep(1)
raise RuntimeError('升级后服务未就绪')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--main-sha', required=True)
parser.add_argument('--admin-sha', required=True)
args = parser.parse_args()
os.umask(0o077)
assert os.geteuid() == 0 and ROOT.resolve() == ROOT and not ROOT.is_symlink()
assert run('hostname') == 'ikun2'
assert '172.31.134.42/20' in run('ip', '-4', 'address', 'show')
assert run('systemctl', 'show', 'smail.service', '--property=User', '--value') == 'smail'
assert run('systemctl', 'is-active', 'smail.service') == 'active'
expected = {'smail': args.main_sha, 'smail-admin': args.admin_sha}
for name, checksum in expected.items():
path = STAGING / name
with path.open('rb') as binary:
header = binary.read(20)
assert header[:5] == b'\x7fELF\x02' and header[18:20] == b'\x3e\x00'
assert digest(path) == checksum
assert not (ROOT / name).is_symlink()
database = database_path(ROOT)
backup = Path('/var/backups') / time.strftime('smail-before-outbox-%Y%m%d-%H%M%S')
backup.mkdir(mode=0o700)
installed = False
subprocess.run(['systemctl', 'stop', 'smail.service'], check=True)
try:
# 停服归档包含配置、密钥和日志,仅保存在远程受保护目录。
archive = backup / 'smail.tar.gz'
subprocess.run(['tar', '-czf', str(archive), '-C', '/opt', 'smail'], check=True)
subprocess.run(['tar', '-dzf', str(archive), '-C', '/opt'], check=True)
archive.chmod(0o600)
assert database.resolve() == database and not database.is_symlink()
snapshot(database, backup / DATABASE_NAME)
for name in expected:
shutil.copy2(ROOT / name, backup / name)
installed = True
for name, checksum in expected.items():
subprocess.run(['install', '-m', '0755', '-o', 'root', '-g', 'root',
str(STAGING / name), str(ROOT / name)], check=True)
assert digest(ROOT / name) == checksum
subprocess.run(['systemctl', 'start', 'smail.service'], check=True)
wait_ready()
version, states = database_state(ROOT)
result = {'backup': str(backup), 'archive_sha256': digest(archive),
'artifacts': expected, 'migration': version, 'queue_states': states,
'service': run('systemctl', 'is-active', 'smail.service'),
'admin_version': run(str(ROOT / 'smail-admin'), 'version'),
'database_integrity': 'ok', 'database_reset': False}
(backup / 'result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(result, ensure_ascii=False, indent=2))
except BaseException:
if installed:
subprocess.run(['systemctl', 'stop', 'smail.service'], check=True)
database = ROOT / 'data' / DATABASE_NAME
assert database.is_file() and not database.is_symlink()
snapshot(database, backup / ('failed-' + DATABASE_NAME))
snapshot(backup / DATABASE_NAME, database)
shutil.chown(database, user='smail', group='smail')
for name in expected:
shutil.copy2(backup / name, ROOT / name)
subprocess.run(['systemctl', 'start', 'smail.service'], check=True)
print('升级失败,备份及故障证据保留于:' + str(backup))
raise
if __name__ == '__main__':
main()