"""ETCD 备份:通过 etcdctl snapshot save 生成快照文件,再流式上传。 配置: - endpoints:列表,如 ["http://10.0.0.1:2379", "http://10.0.0.2:2379"] - username/password:可选 - cacert/cert/key:可选 TLS """ import asyncio import shutil import tarfile import tempfile from datetime import datetime from pathlib import Path from typing import AsyncIterator from app.core.backup.base import BackupMetadata, BaseBackup from app.utils.logging import get_logger log = get_logger(__name__) class EtcdBackup(BaseBackup): def __init__(self, source_config: dict, job_name: str): super().__init__(source_config, job_name) # endpoints 接收 string(逗号分隔)或 list eps = source_config.get("endpoints", "") if isinstance(eps, str): self.endpoints: list[str] = [e.strip() for e in eps.split(",") if e.strip()] else: self.endpoints = list(eps) if not self.endpoints: raise ValueError("ETCD endpoints 不能为空") self.username: str = source_config.get("username", "") self.password: str = source_config.get("password", "") self.cacert: str = source_config.get("cacert", "") self.cert: str = source_config.get("cert", "") self.key: str = source_config.get("key", "") def metadata(self) -> BackupMetadata: ts = datetime.now().strftime("%Y%m%d-%H%M%S") return BackupMetadata( suggested_filename=f"{self.job_name}-etcd-{ts}.tar.gz", content_type="application/gzip", extra={ "endpoints": self.endpoints, "has_tls": bool(self.cacert or self.cert or self.key), "has_auth": bool(self.username), }, ) async def produce(self) -> AsyncIterator[bytes]: tmpdir = Path(tempfile.mkdtemp(prefix=f"etcdbk_{self.job_name}_")) snap_path = tmpdir / "etcd.snap" try: cmd = ["etcdctl", "snapshot", "save", str(snap_path)] # 多次 --endpoints 让 etcdctl 选择健康节点 for ep in self.endpoints: cmd.extend(["--endpoints", ep]) if self.username: cmd.extend(["--user", f"{self.username}:{self.password}"]) if self.cacert: cmd.extend(["--cacert", self.cacert]) if self.cert: cmd.extend(["--cert", self.cert]) if self.key: cmd.extend(["--key", self.key]) log.info("Running: etcdctl snapshot save (endpoints=%s)", self.endpoints) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() if proc.returncode != 0: raise RuntimeError( f"etcdctl snapshot save failed (exit={proc.returncode}): " f"{(stderr or stdout).decode(errors='replace')[:2000]}" ) if not snap_path.exists() or snap_path.stat().st_size == 0: raise RuntimeError("etcd snapshot 文件为空,请检查 ETCD 连接") # 写入 metadata 文件并 tar.gz 打包 log.info("Snapshot size: %s bytes; packing...", snap_path.stat().st_size) meta_path = tmpdir / "META.txt" meta_path.write_text( f"etcd_endpoints={','.join(self.endpoints)}\n" f"backup_time={datetime.utcnow().isoformat()}Z\n" f"snapshot_size={snap_path.stat().st_size}\n" f"restore_hint=etcdctl snapshot restore " f"--name --initial-cluster --initial-advertise-peer-urls \n", encoding="utf-8", ) tar_path = tmpdir / "backup.tar.gz" loop = asyncio.get_event_loop() def _tar() -> None: with tarfile.open(str(tar_path), mode="w:gz") as tar: tar.add(str(snap_path), arcname="etcd.snap") tar.add(str(meta_path), arcname="META.txt") await loop.run_in_executor(None, _tar) # 流式产出 with open(tar_path, "rb") as f: while True: chunk = await loop.run_in_executor(None, f.read, 64 * 1024) if not chunk: break yield chunk finally: shutil.rmtree(tmpdir, ignore_errors=True)