统一跨平台备份入口,校验快照位点、完整性及增量链后隔离恢复。 末个 binlog 按读取位点归档,避免轮转后并发事务被续接跳过。 修复 Windows 安装器的编码、默认路径和花括号路径参数。
145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
"""备份失败不得伪装成功,损坏文件及断链不得进入恢复。"""
|
|
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"payroll_backup", Path(__file__).resolve().parents[3] / "scripts/payroll_backup.py"
|
|
)
|
|
backup = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(backup)
|
|
|
|
|
|
@pytest.fixture
|
|
def chain(tmp_path):
|
|
data = tmp_path / "full.sql.gz"
|
|
data.write_bytes(b"fixture")
|
|
baseline = {"kind": "FULL", "database": "payroll", "server_uuid": "server-a",
|
|
"point": {"file": "binlog.000001", "position": 100},
|
|
"entries": [{"file": data.name, "sha256": backup.checksum(data)}]}
|
|
backup.json_write(tmp_path / "full.json", baseline)
|
|
state = {"full": "full.json", "point": baseline["point"], "increments": []}
|
|
backup.json_write(tmp_path / "state.json", state)
|
|
return SimpleNamespace(directory=tmp_path, container="test", database="payroll"), baseline, state
|
|
|
|
|
|
def test_备份篡改与路径越界必须拒绝(chain):
|
|
args, _, _ = chain
|
|
(args.directory / "full.sql.gz").write_bytes(b"corrupted")
|
|
with pytest.raises(backup.BackupError, match="SHA-256"):
|
|
backup.checked_chain(args)
|
|
with pytest.raises(backup.BackupError, match="文件名"):
|
|
backup.load_manifest(args.directory, "../external.json")
|
|
|
|
|
|
def test_跨全量与位点断链拒绝恢复(chain):
|
|
args, _, state = chain
|
|
change = {"full": "other.json", "start": state["point"], "end": state["point"], "entries": []}
|
|
backup.json_write(args.directory / "increment.json", change)
|
|
state["increments"] = ["increment.json"]
|
|
backup.json_write(args.directory / "state.json", state)
|
|
with pytest.raises(backup.BackupError, match="不连续"):
|
|
backup.checked_chain(args)
|
|
|
|
|
|
def test_无更新不轮转日志也不生成增量(chain, monkeypatch):
|
|
args, baseline, state = chain
|
|
calls = []
|
|
def sql(*values):
|
|
calls.append(values)
|
|
return baseline["server_uuid"]
|
|
monkeypatch.setattr(backup, "mysql", sql)
|
|
monkeypatch.setattr(backup, "position", lambda _: state["point"])
|
|
assert backup.incremental(args)["status"] == "UNCHANGED"
|
|
assert len(calls) == 1
|
|
assert not list(args.directory.glob("increment*"))
|
|
|
|
|
|
def test_服务器更换后不续接旧链(chain, monkeypatch):
|
|
args, _, _ = chain
|
|
monkeypatch.setattr(backup, "mysql", lambda *a: "server-b")
|
|
with pytest.raises(backup.BackupError, match="身份已变化"):
|
|
backup.incremental(args)
|
|
|
|
|
|
def test_备份检查拒绝同服务器上的其他数据库(chain, monkeypatch):
|
|
args, _, _ = chain
|
|
args.database = "other_payroll"
|
|
calls = []
|
|
monkeypatch.setattr(backup, "mysql", lambda *a: calls.append(a))
|
|
with pytest.raises(backup.BackupError, match="检查目标不符"):
|
|
backup.check(args)
|
|
assert not calls
|
|
|
|
|
|
def test_轮转后的并发事务归档到末位点且续接不跳过(chain, monkeypatch):
|
|
args, baseline, state = chain
|
|
start = state["point"]
|
|
end = {"file": "binlog.000002", "position": 400}
|
|
monkeypatch.setattr(backup, "mysql", lambda *a: baseline["server_uuid"])
|
|
monkeypatch.setattr(backup, "position", lambda _: end)
|
|
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000001", "binlog.000002"])
|
|
commands = []
|
|
|
|
def archive(command, path):
|
|
commands.append(command)
|
|
path.write_bytes(b"archived")
|
|
return {"file": path.name, "sha256": backup.checksum(path)}
|
|
|
|
monkeypatch.setattr(backup, "stream_gzip", archive)
|
|
first = backup.incremental(args)
|
|
manifest = backup.load_manifest(args.directory, first["manifest"])
|
|
assert manifest["start"] == start
|
|
assert manifest["end"] == end
|
|
assert [entry["binlog"] for entry in manifest["entries"]] == [
|
|
"binlog.000001", "binlog.000002"
|
|
]
|
|
assert commands[-1][-4:] == ["head", "-c", "400", "/var/lib/mysql/binlog.000002"]
|
|
|
|
# 下一轮仍从上一轮已归档的末位点续接,跨轮不会遗漏或重复计算事务。
|
|
next_end = {"file": "binlog.000003", "position": 157}
|
|
monkeypatch.setattr(backup, "position", lambda _: next_end)
|
|
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000002", "binlog.000003"])
|
|
second = backup.incremental(args)
|
|
next_manifest = backup.load_manifest(args.directory, second["manifest"])
|
|
assert next_manifest["start"] == end
|
|
assert [entry["binlog"] for entry in next_manifest["entries"]] == [
|
|
"binlog.000002", "binlog.000003"
|
|
]
|
|
assert backup.checked_chain(args)[1] == [manifest, next_manifest]
|
|
|
|
|
|
def test_损坏清单不得启动任何恢复容器(chain, monkeypatch):
|
|
args, _, _ = chain
|
|
(args.directory / "full.sql.gz").write_bytes(b"broken")
|
|
commands = []
|
|
monkeypatch.setattr(backup, "run", lambda command, **kw: commands.append(command))
|
|
with pytest.raises(backup.BackupError):
|
|
backup.restore(args)
|
|
assert commands == []
|
|
|
|
|
|
def test_清单原子落盘不留下半成品(tmp_path):
|
|
target = tmp_path / "manifest.json"
|
|
backup.json_write(target, {"status": "SUCCEEDED"})
|
|
assert json.loads(target.read_text())["status"] == "SUCCEEDED"
|
|
assert not list(tmp_path.glob("*.partial"))
|
|
|
|
|
|
def test_MySQL探活使用TCP避开镜像初始化临时服务(monkeypatch):
|
|
commands = []
|
|
|
|
def run(command, **kwargs):
|
|
commands.append(command)
|
|
assert kwargs["data"] == b"SELECT 1"
|
|
return b"1\n"
|
|
|
|
monkeypatch.setattr(backup, "run", run)
|
|
assert backup.mysql("isolated-test", "SELECT 1") == "1"
|
|
command = commands[0]
|
|
assert "--host=127.0.0.1 --protocol=TCP" in command[command.index("-c") + 1]
|