| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- import argparse
- import hashlib
- import json
- import os
- import re
- import subprocess
- import time
- from contextlib import contextmanager
- from datetime import UTC, datetime
- from pathlib import Path
- from uuid import uuid4
- ROOT = Path(__file__).resolve().parents[1]
- BACKUPS = ROOT / ".work" / "redis-backups"
- TRANSIENT = ("agent:active:global*", "sse:global", "sse:actor:*", "monitor:worker:*")
- def docker(*args, **kwargs):
- return subprocess.run(["docker", *args], check=True, timeout=180, **kwargs)
- def cli(container, *args):
- result = docker("exec", container, "redis-cli", "-e", "--json", *args, capture_output=True)
- return json.loads(result.stdout)
- def digest(path):
- with path.open("rb") as stream:
- return hashlib.file_digest(stream, "sha256").hexdigest()
- def backup():
- """从本机 Compose 的 redis-state 获取包含模块数据和绝对到期时间的 RDB。"""
- source = docker("compose", "ps", "-q", "redis-state", cwd=ROOT, capture_output=True)
- source_id = source.stdout.decode().strip()
- if not source_id:
- raise RuntimeError("redis-state 容器未启动")
- metadata = json.loads(docker("inspect", source_id, capture_output=True).stdout)[0]
- directory = BACKUPS / (datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])
- directory.mkdir(parents=True)
- entry = {
- "format": 1,
- "started_at": datetime.now(UTC).isoformat(),
- "image_id": metadata["Image"],
- "indexes": cli(source_id, "FT._LIST"),
- }
- remote = "/data/recovery-" + uuid4().hex + ".rdb"
- path = directory / "state.rdb"
- try:
- # 使用 Redis 自身的快照传输,包含 RedisJSON 和 Search 模块持久数据。
- docker("exec", source_id, "redis-cli", "--rdb", remote, capture_output=True)
- docker("cp", source_id + ":" + remote, str(path), capture_output=True)
- finally:
- docker("exec", source_id, "rm", "-f", remote, capture_output=True)
- entry.update(
- finished_at=datetime.now(UTC).isoformat(), sha256=digest(path), bytes=path.stat().st_size
- )
- (directory / "manifest.json").write_text(json.dumps(entry, indent=2), encoding="utf-8")
- return directory
- def validate(directory):
- directory = directory.resolve(strict=True)
- if not directory.is_relative_to(BACKUPS.resolve()):
- raise ValueError("恢复文件必须位于项目 .work/redis-backups")
- entry = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
- path = (directory / "state.rdb").resolve(strict=True)
- if entry["format"] != 1 or not re.fullmatch(r"sha256:[0-9a-f]{64}", entry["image_id"]):
- raise ValueError("备份清单格式不符合要求")
- if not path.is_relative_to(directory) or digest(path) != entry["sha256"]:
- raise ValueError("备份文件 SHA256 校验失败")
- if path.stat().st_size != entry["bytes"]:
- raise ValueError("备份文件大小不符合要求")
- return directory, entry
- @contextmanager
- def recovered(directory):
- """仅创建无网络、无公开端口的独立 Redis;离开上下文时删除本次容器。"""
- directory, entry = validate(directory)
- name = "zhifu-redis-restore-" + uuid4().hex[:12]
- docker(
- "run",
- "-d",
- "--name",
- name,
- "--network",
- "none",
- "--read-only",
- "--memory",
- "1g",
- "--label",
- "zhifu.purpose=redis-recovery-verification",
- "--label",
- "zhifu.recovery-run=" + os.environ.get("ZHIFU_RECOVERY_RUN_ID", "standalone"),
- "--mount",
- f"type=bind,source={directory},target=/backup,readonly",
- entry["image_id"],
- "redis-server",
- "--dir",
- "/backup",
- "--dbfilename",
- "state.rdb",
- "--appendonly",
- "no",
- "--save",
- "",
- capture_output=True,
- )
- try:
- for _ in range(60):
- state = json.loads(docker("inspect", name, capture_output=True).stdout)[0]["State"]
- if not state["Running"]:
- raise RuntimeError("独立 Redis 恢复容器启动失败")
- ready = subprocess.run(
- ["docker", "exec", name, "redis-cli", "-e", "--json", "PING"],
- capture_output=True,
- timeout=5,
- )
- if ready.returncode == 0 and json.loads(ready.stdout) == "PONG":
- break
- time.sleep(0.5)
- else:
- raise TimeoutError("独立 Redis 恢复容器启动超时")
- yield name, entry
- finally:
- docker("rm", "-f", name, capture_output=True)
- def clear_transient(container):
- """仅允许清除本工具创建的隔离容器中的旧运行额度、SSE 连接和进程心跳。"""
- metadata = json.loads(docker("inspect", container, capture_output=True).stdout)[0]
- if (
- metadata["HostConfig"]["NetworkMode"] != "none"
- or metadata["Config"]["Labels"].get("zhifu.purpose") != "redis-recovery-verification"
- ):
- raise ValueError("仅允许处理独立恢复容器")
- removed = 0
- for pattern in TRANSIENT:
- cursor = "0"
- while True:
- cursor, keys = cli(container, "SCAN", cursor, "MATCH", pattern, "COUNT", "100")
- if keys:
- removed += cli(container, "UNLINK", *keys)
- if str(cursor) == "0":
- break
- cursor = str(cursor)
- return removed
- def verify(directory):
- started = time.monotonic()
- with recovered(directory) as (container, entry):
- indexes = cli(container, "FT._LIST")
- if not set(entry["indexes"]).issubset(indexes):
- raise RuntimeError("恢复后的 Search 索引不完整")
- report = {
- "started_at": datetime.now(UTC).isoformat(),
- "sha256_verified": True,
- "keys_before_cleanup": cli(container, "DBSIZE"),
- "indexes": indexes,
- "transient_removed": clear_transient(container),
- "keys_after_cleanup": cli(container, "DBSIZE"),
- }
- report["seconds"] = round(time.monotonic() - started, 2)
- (directory / "restore-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
- return report
- if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Redis state 备份与独立容器恢复验证")
- parser.add_argument("action", choices=["backup", "verify", "exercise"])
- parser.add_argument("--directory", type=Path)
- args = parser.parse_args()
- if args.action == "verify":
- if args.directory is None:
- parser.error("verify 需要 --directory")
- print(json.dumps(verify(args.directory), indent=2))
- else:
- destination = backup()
- print(destination)
- if args.action == "exercise":
- print(json.dumps(verify(destination), indent=2))
|