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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""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)
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.backup.base import BaseBackup
|
||||
from app.core.backup.directory import DirectoryBackup
|
||||
from app.core.backup.etcd import EtcdBackup
|
||||
from app.core.backup.influxdb import InfluxDBBackup
|
||||
from app.core.backup.mysql import MySQLBackup
|
||||
from app.core.retention import apply_retention
|
||||
@@ -35,6 +36,8 @@ def _make_backup(job: Job) -> BaseBackup:
|
||||
return DirectoryBackup(src, job.name)
|
||||
if job.type == "influxdb":
|
||||
return InfluxDBBackup(src, job.name)
|
||||
if job.type == "etcd":
|
||||
return EtcdBackup(src, job.name)
|
||||
raise ValueError(f"Unsupported job type: {job.type}")
|
||||
|
||||
|
||||
@@ -127,6 +130,63 @@ async def run_backup(run_id: int, trigger: str = "manual") -> None:
|
||||
db.add(run_log)
|
||||
db.commit()
|
||||
log.info("Backup run_id=%s finished status=%s", run_id, run.status)
|
||||
|
||||
# 触发通知(在事务外,避免阻塞 executor;fire and forget)
|
||||
try:
|
||||
should_notify = (
|
||||
job.notify_on == "all"
|
||||
or (job.notify_on == "failure" and run.status == "failed")
|
||||
)
|
||||
if should_notify:
|
||||
asyncio.create_task(_dispatch_notification(job.id, run.id))
|
||||
except Exception as e:
|
||||
log.warning("Failed to schedule notification: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _dispatch_notification(job_id: int, run_id: int) -> None:
|
||||
"""从 DB 重新加载最新 run 状态后发通知。"""
|
||||
from app.core.notify import NotificationContext, send_notifications
|
||||
from app.models.setting import Setting
|
||||
from app.utils.crypto import decrypt_dict
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.get(Job, job_id)
|
||||
run = db.get(Run, run_id)
|
||||
if not job or not run:
|
||||
return
|
||||
|
||||
notify_row = db.get(Setting, "notifications")
|
||||
if not notify_row or not notify_row.value:
|
||||
return
|
||||
try:
|
||||
settings = decrypt_dict(notify_row.value)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not (settings.get("email_enabled") or settings.get("feishu_enabled")):
|
||||
return
|
||||
|
||||
ctx = NotificationContext(
|
||||
job_name=job.name,
|
||||
job_type=job.type,
|
||||
status=run.status,
|
||||
run_id=run.id,
|
||||
artifact_size=run.artifact_size,
|
||||
duration_seconds=run.duration_seconds,
|
||||
error_message=run.error_message,
|
||||
artifact_path=run.artifact_path,
|
||||
started_at=run.started_at.isoformat() if run.started_at else None,
|
||||
finished_at=run.finished_at.isoformat() if run.finished_at else None,
|
||||
)
|
||||
|
||||
result = await send_notifications(settings, ctx)
|
||||
log.info(
|
||||
"Notification dispatched for run_id=%s: email_ok=%s feishu_ok=%s",
|
||||
run.id, result.email_ok, result.feishu_ok,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""通知模块:邮件 (SMTP) + 飞书群机器人 (Webhook)。
|
||||
|
||||
设计:纯函数式 + 异步,调用方传入配置 dict 与上下文。
|
||||
不依赖数据库或 settings 表(settings 读取由调用方负责)。
|
||||
"""
|
||||
import asyncio
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Iterable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationContext:
|
||||
job_name: str
|
||||
job_type: str
|
||||
status: str # "success" / "failed"
|
||||
run_id: int
|
||||
artifact_size: Optional[int] = None
|
||||
duration_seconds: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
artifact_path: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
def _human_size(n: Optional[int]) -> str:
|
||||
if n is None:
|
||||
return "-"
|
||||
if n < 1024:
|
||||
return f"{n} B"
|
||||
if n < 1024 * 1024:
|
||||
return f"{n / 1024:.1f} KB"
|
||||
if n < 1024 * 1024 * 1024:
|
||||
return f"{n / 1024 / 1024:.1f} MB"
|
||||
return f"{n / 1024 / 1024 / 1024:.2f} GB"
|
||||
|
||||
|
||||
def _build_text(ctx: NotificationContext) -> str:
|
||||
lines = [
|
||||
f"[备份系统] {ctx.job_name}",
|
||||
f"状态: {ctx.status.upper()}",
|
||||
f"类型: {ctx.job_type}",
|
||||
f"Run ID: {ctx.run_id}",
|
||||
]
|
||||
if ctx.started_at:
|
||||
lines.append(f"开始: {ctx.started_at}")
|
||||
if ctx.finished_at:
|
||||
lines.append(f"结束: {ctx.finished_at}")
|
||||
if ctx.duration_seconds is not None:
|
||||
lines.append(f"耗时: {ctx.duration_seconds}s")
|
||||
if ctx.artifact_size is not None:
|
||||
lines.append(f"大小: {_human_size(ctx.artifact_size)}")
|
||||
if ctx.artifact_path:
|
||||
lines.append(f"路径: {ctx.artifact_path}")
|
||||
if ctx.error_message:
|
||||
lines.append(f"错误: {ctx.error_message[:1000]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_feishu_card(ctx: NotificationContext) -> dict:
|
||||
color = "green" if ctx.status == "success" else "red"
|
||||
fields = [
|
||||
{"is_short": True, "text": {"tag": "lark_md", "content": f"**类型**\n{ctx.job_type}"}},
|
||||
{"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{ctx.run_id}"}},
|
||||
]
|
||||
if ctx.duration_seconds is not None:
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{ctx.duration_seconds}s"}})
|
||||
if ctx.artifact_size is not None:
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**大小**\n{_human_size(ctx.artifact_size)}"}})
|
||||
if ctx.error_message:
|
||||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**错误**\n{ctx.error_message[:1000]}"}})
|
||||
|
||||
return {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"[备份] {ctx.job_name} — {ctx.status.upper()}"},
|
||||
"template": color,
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "div", "fields": fields},
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [
|
||||
{"tag": "plain_text", "content": f"Backup System • {ctx.started_at or ''}"}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ===== Email =====
|
||||
|
||||
async def send_email(
|
||||
smtp_config: dict,
|
||||
to_addrs: Iterable[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""通过 SMTP 发送邮件。smtp_config 必填字段:
|
||||
host, port, user, password, from_addr, use_tls (bool)
|
||||
"""
|
||||
host = smtp_config.get("host")
|
||||
if not host:
|
||||
return False, "SMTP host 未配置"
|
||||
port = int(smtp_config.get("port") or 587)
|
||||
user = smtp_config.get("user", "")
|
||||
password = smtp_config.get("password", "")
|
||||
from_addr = smtp_config.get("from_addr") or user
|
||||
use_tls = bool(smtp_config.get("use_tls", True))
|
||||
|
||||
to_list = [a.strip() for a in to_addrs if a and a.strip()]
|
||||
if not to_list:
|
||||
return False, "收件人列表为空"
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = ", ".join(to_list)
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
def _send() -> tuple[bool, str]:
|
||||
try:
|
||||
if use_tls:
|
||||
client = smtplib.SMTP(host, port, timeout=15)
|
||||
client.ehlo()
|
||||
client.starttls()
|
||||
client.ehlo()
|
||||
else:
|
||||
client = smtplib.SMTP_SSL(host, port, timeout=15)
|
||||
client.ehlo()
|
||||
if user:
|
||||
client.login(user, password)
|
||||
client.sendmail(from_addr, to_list, msg.as_string())
|
||||
client.quit()
|
||||
return True, f"sent to {len(to_list)} recipient(s)"
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
ok, msg_text = await asyncio.get_event_loop().run_in_executor(None, _send)
|
||||
return ok, msg_text
|
||||
|
||||
|
||||
# ===== Feishu Webhook =====
|
||||
|
||||
async def send_feishu(webhook_url: str, ctx: NotificationContext) -> tuple[bool, str]:
|
||||
"""飞书自定义机器人:发送 interactive card。"""
|
||||
if not webhook_url:
|
||||
return False, "webhook URL 未配置"
|
||||
payload = _build_feishu_card(ctx)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(webhook_url, json=payload)
|
||||
if resp.status_code != 200:
|
||||
return False, f"HTTP {resp.status_code}: {resp.text[:300]}"
|
||||
data = resp.json()
|
||||
if data.get("code") not in (0, None):
|
||||
return False, f"feishu err: {data.get('msg') or data}"
|
||||
return True, "ok"
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
# ===== Aggregator =====
|
||||
|
||||
@dataclass
|
||||
class NotifyResult:
|
||||
email_ok: bool = True
|
||||
email_msg: str = ""
|
||||
feishu_ok: bool = True
|
||||
feishu_msg: str = ""
|
||||
|
||||
|
||||
async def send_notifications(
|
||||
notify_settings: dict,
|
||||
ctx: NotificationContext,
|
||||
) -> NotifyResult:
|
||||
"""根据 settings 发送通知。settings 结构:
|
||||
|
||||
{
|
||||
"email_enabled": true,
|
||||
"smtp": {"host", "port", "user", "password", "from_addr", "use_tls"},
|
||||
"email_to": ["ops@example.com", ...],
|
||||
"feishu_enabled": true,
|
||||
"feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/...",
|
||||
}
|
||||
"""
|
||||
res = NotifyResult()
|
||||
|
||||
if notify_settings.get("email_enabled"):
|
||||
smtp = notify_settings.get("smtp") or {}
|
||||
to_list = notify_settings.get("email_to") or []
|
||||
subject = f"[{'成功' if ctx.status == 'success' else '失败'}] 备份任务 {ctx.job_name}"
|
||||
ok, msg = await send_email(smtp, to_list, subject, _build_text(ctx))
|
||||
res.email_ok = ok
|
||||
res.email_msg = msg
|
||||
log.info("Email notify: ok=%s msg=%s", ok, msg)
|
||||
|
||||
if notify_settings.get("feishu_enabled"):
|
||||
url = notify_settings.get("feishu_webhook_url", "")
|
||||
ok, msg = await send_feishu(url, ctx)
|
||||
res.feishu_ok = ok
|
||||
res.feishu_msg = msg
|
||||
log.info("Feishu notify: ok=%s msg=%s", ok, msg)
|
||||
|
||||
return res
|
||||
Reference in New Issue
Block a user