07b2016f33
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>
118 lines
4.5 KiB
Python
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)
|