Files
system-backu/backend/app/core/backup/etcd.py
T
publ 07b2016f33 feat: ETCD 备份 + 邮件/飞书通知
ETCD 备份(新增类型):
- 后端 core/backup/etcd.py:调用 etcdctl snapshot save(v3.5.17 二进制)
  生成快照文件,与 META.txt 一起 tar.gz 流式打包上传。
- 配置支持 endpoints(逗号分隔多节点)、basic auth、TLS(cacert/cert/key)。
- Dockerfile 下载 etcdctl 二进制(绕过 apt 签名问题)。

通知模块:
- 核心 app/core/notify.py:
  - 邮件:smtplib + MIMEMultipart(线程池里跑避免阻塞)
  - 飞书:httpx 调自定义机器人 webhook,发 interactive card
- settings API:
  - GET/PUT /api/settings/notifications(密码脱敏 *** 编辑占位)
  - POST /api/settings/notifications/test 测试通道
- Job 增加 notify_on 字段(none/failure/all),Alembic 0002 迁移。
- executor 在 run 完成后根据 job.notify_on 与 run.status 异步触发通知,
  重新加载最新 run 信息生成上下文。

前端:
- JobForm 加 ETCD 表单字段与 notify_on 选择器;type 选项加 etcd。
- JobList 显示通知策略徽标。
- Settings 页加通知配置卡片:邮件(折叠面板 + 测试按钮)+ 飞书 + 保存。
- 新增 api/settings.ts。
- 类型扩展:EtcdSourceConfig、NotificationsConfig、SmtpConfig。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 11:24:53 +08:00

118 lines
4.5 KiB
Python

"""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 <file> "
f"--name <name> --initial-cluster <cluster> --initial-advertise-peer-urls <url>\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)