Files
BackupX/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md
T
Wu Qing f7596bd319 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力

围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。

## 集群能力

- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
  数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)

## 企业功能

- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出

## 规模化运维

- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)

## 体验 & 可达性

- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)

## 合规 & 可部署

- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)

## 破坏性变更

- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
  (原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)

* 修复: CodeQL 安全扫描告警

- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
  高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)

* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper

- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
2026-04-20 13:04:13 +08:00

289 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档:集群感知恢复功能 & 任务节点选择器
- 日期:2026-04-19
- 状态:已通过(用户授权自主执行)
- 影响范围:server、web、agent
- 关联讨论:社区反馈"PVE 服务器能备份吗?有没有一键恢复"及作者回复"好像写成 bug 了"、"一键恢复后续优化"
## 1. 问题定义
### 1.1 B1 — 任务表单缺少执行节点选择器(Bug)
`web/src/components/backup-tasks/BackupTaskFormDrawer.tsx` 的草稿对象里有 `nodeId: 0` 字段,编辑时也能从 `initialValue.nodeId` 回填,但三步表单(基础/源/存储策略)**完全没有任何 Select 让用户选择节点**。结果:
- 所有任务被迫以 `nodeId = 0` 创建(Master 本地执行)
- 已安装的远程 Agent 根本拉不到 `run_task` 命令
- 多节点集群的核心价值失效
后端 `BackupExecutionService.startTask` 通过 `isRemoteNode(task.NodeID)` 判断路由,能力本就支持远程执行,缺口只在 UI。
### 1.2 恢复功能底层错误(架构级)
`server/internal/service/backup_execution_service.go:175 RestoreRecord`
1. **同步阻塞**:HTTP POST 同步执行完整恢复流程,大文件/大库必超时
2. **忽视节点路由**:总是在 Master 本地 `runner.Restore`,无论任务绑定哪个节点
3. **无日志/无记录**:传 `backup.NopLogWriter{}`,用户看不到任何进度或失败原因;未建独立恢复记录
4. **前端误用状态**`BackupRecordLogDrawer.handleRestore` 把"恢复已提交"塞进 `setStreamError`UI 渲染为黄色警告
**架构后果**:任务绑定到 Agent 节点 A(源文件/数据库只在 A 可达)时,点击恢复 → Master 下载备份 → Master 本地恢复 → **文件写到 Master 的 `/var/www`、连 Master 本地不存在的数据库**。完全错的机器。
Agent 端 `server/internal/agent/executor.go` 只实现了 `handleRunTask``handleListDir`,从设计上就没有恢复能力。
## 2. 设计目标
- 恢复与备份**对称**:支持本地/远程节点路由,同一套设施(AgentCommand 队列、日志流)
- 恢复一等公民:独立 `RestoreRecord` 模型 + 异步执行 + LogHub SSE + 列表页
- 破坏性操作必须**可见且可确认**:前端恢复前弹窗展示目标位置、覆盖警告
- 复用现有基建,不引入新依赖/新抽象层
## 3. 架构设计
### 3.1 数据层
```go
// model/restore_record.go
type RestoreRecord struct {
ID uint
BackupRecordID uint // 源备份记录
TaskID uint // 冗余:便于筛选
NodeID uint // 在哪个节点执行
Status string // running|success|failed
ErrorMessage string
LogContent string
DurationSeconds int
StartedAt time.Time
CompletedAt *time.Time
TriggeredBy string // 用户名(审计冗余)
CreatedAt, UpdatedAt
}
```
迁移:`database.go``AutoMigrate` 增加 `&model.RestoreRecord{}`
### 3.2 服务层
新增 `service.RestoreService`
```go
type RestoreService struct {
restores repository.RestoreRecordRepository
records repository.BackupRecordRepository
tasks repository.BackupTaskRepository
targets repository.StorageTargetRepository
nodeRepo repository.NodeRepository
storage *storage.Registry
runners *backup.Registry
logHub *backup.LogHub
cipher *codec.ConfigCipher
dispatcher AgentDispatcher
// ...依赖同 BackupExecutionService
}
// 启动恢复:同步创建 RestoreRecord → 判断路由 → 返回记录
func (s *RestoreService) Start(ctx, backupRecordID, triggeredBy) (*RestoreRecordDetail, error)
// Master 本地执行:下载 → 解密/解压 → runner.Restore(LogSink → LogHub)
func (s *RestoreService) executeLocally(ctx, restoreID)
// Agent 路由:EnqueueCommand("restore_record", {restoreRecordId})
func (s *RestoreService) dispatchToAgent(ctx, restore *model.RestoreRecord)
```
路由决策:
```
restore := 创建 RestoreRecord(status=running, nodeId=task.NodeID)
if isRemoteNode(task.NodeID):
EnqueueCommand(nodeID, "restore_record", {restoreRecordId: restore.ID})
else:
go executeLocally(restore.ID) // 复用 BackupExecutionService.semaphore? 不,独立通道避免阻塞备份
return restore
```
### 3.3 Agent 端
#### 3.3.1 新增命令类型
`model/agent_command.go`
```go
const AgentCommandTypeRestoreRecord = "restore_record" // Payload: {"restoreRecordId": N}
```
#### 3.3.2 Master ↔ Agent API(复用 Agent API 组)
- `GET /api/agent/restores/:id/spec` → 返回 `AgentRestoreSpec`(已解密存储配置、任务 spec、备份记录 storagePath/fileName
- `POST /api/agent/restores/:id``AgentRestoreUpdate`status / errorMessage / logAppend
`AgentRestoreSpec`
```go
type AgentRestoreSpec struct {
RestoreRecordID uint
BackupRecordID uint
TaskID uint
TaskName, Type string
SourcePath string
SourcePaths string
DBHost, DBName string
// ... 同 AgentTaskSpec 的任务字段
Storage AgentStorageTargetConfig // 只需下载源目标
StoragePath string // 远端对象 key
FileName string
Compression string
Encrypt bool // 当前 Agent 不支持加密恢复,直接返回失败
}
```
#### 3.3.3 Agent Executor
`agent/executor.go` 新增 `ExecuteRestore(restoreRecordID)`
1. `client.GetRestoreSpec(restoreRecordID)`
2.`Encrypt == true``UpdateRestoreRecord(status=failed, errorMessage="Agent 不支持加密恢复")`
3. 临时目录下载备份文件(通过 storage provider `Download`
4. `.enc``.gz` 的逆向处理(当前不支持加密;`.gz``compress.GunzipFile`
5. `runner.Restore(backupSpec, preparedPath, restoreLogger)` — logger 把每行通过 `UpdateRestoreRecord{LogAppend}` 回传
6. 成功 → `UpdateRestoreRecord(status=success)`
`agent/agent.go``switch cmd.Type` 增加 `"restore_record": handleRestoreRecord`
### 3.4 HTTP 层
新增 handler `restore_record_handler.go`
```
POST /api/backup/records/:id/restore → 202body: {restoreRecordId}
GET /api/restore/records → 列表(支持 ?taskId, ?status 筛选)
GET /api/restore/records/:id → 详情(含 logContent
GET /api/restore/records/:id/logs/stream → SSE(复用 LogHubsequence 事件协议)
```
Agent 端点 `agent_handler.go`
```
GET /api/agent/restores/:id/spec
POST /api/agent/restores/:id
```
`router.go` 对应注册。注意:`LogHub` 的 recordID 命名空间是 `uint`,恢复记录 ID 可能与备份记录 ID 冲突 → 决策:
- **方案**LogHub 加 `topic` 维度 —— 工作量较大
- **简化方案**:恢复记录用 `restoreID + 常量偏移` 或使用独立 `LogHub` 实例
本次选择**独立 LogHub 实例**`RestoreLogHub`),彻底隔离,代码量最小。
### 3.5 前端
#### 3.5.1 修 B1 — 节点选择器
`BackupTaskFormDrawer.tsx`
- 已有 `localNodeId` prop
- 新增 `nodes: NodeSummary[]` prop(由父组件传入)
- `renderBasicStep()` 增加:
```tsx
<div>
<Typography.Text>执行节点</Typography.Text>
<Select
value={draft.nodeId || undefined}
placeholder="留空或选择本机 = 在 Master 执行"
allowClear
options={nodeOptions} // [{label: `${name} (${status})`, value: id}]
onChange={(value) => updateDraft({ nodeId: Number(value ?? 0) })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
任务将在该节点上执行备份与恢复;源路径/数据库以该节点视角解析。
</Typography.Paragraph>
</div>
```
`BackupTasksPage`:把已加载的 `nodeList` 传给 FormDrawer。
#### 3.5.2 恢复 UX
- `BackupRecordLogDrawer.handleRestore`
- 打开 `RestoreConfirmDialog`(列出将覆盖的目标路径/数据库 + 执行节点 + 风险说明)
- 确认后 POST restore,拿 `restoreRecordId`
- `Message.success('恢复已启动,正在打开日志')`
- 关闭抽屉 → `navigate('/restore/records?restoreId=X')`
- 新增 `components/restore-records/RestoreRecordLogDrawer.tsx`(结构复刻 BackupRecordLogDrawer,去掉下载/删除按钮)
- 新增 `pages/restore-records/RestoreRecordsPage.tsx`(列表 + 状态 tag + 点击打开 Drawer
- `router/index.tsx``restore/records` 路由
- `layouts/AppLayout.tsx` 菜单加"恢复记录"
#### 3.5.3 Types & Services
- `types/restore-records.ts`
- `services/restore-records.ts``listRestoreRecords``getRestoreRecord``startRestoreFromBackup``streamRestoreRecordLogs`
### 3.6 依赖注入(app.go
```go
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
restoreService := service.NewRestoreService(
restoreRecordRepo, backupRecordRepo, backupTaskRepo, storageTargetRepo,
nodeRepo, storageRegistry, backupRunnerRegistry, restoreLogHub, configCipher,
agentService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent)
// 注入到 router
```
`BackupRecordHandler.Restore` 改为委托给 `RestoreService.Start`。旧的 `BackupExecutionService.RestoreRecord` 保留(本地执行逻辑抽取到 RestoreService 复用),对外 HTTP 契约变更:
- **新契约**`POST /backup/records/:id/restore` 返回 `{restoreRecordId: N}`(前端改为跳转到恢复详情页,而不是等同步完成)
- **Agent**:新增 `handleRestoreRecord`
### 3.7 安全性
- 恢复是破坏性操作:后端审计日志已记录
- 前端二次确认
- 路径穿越:`FileRunner.Restore` 已有 `strings.HasPrefix` 校验 targetParent,沿用
### 3.8 迁移与兼容性
-`BackupRecordService.Restore` 方法保留,改为内部调用新 `RestoreService.Start`(避免外部使用方报错)—— 但 HTTP 输出变化是已知 breaking
- 因为"恢复"目前是废的(见底层错误),前端无历史记录显示,破坏性 HTTP 变更可接受
- 数据库无删表操作,只 AutoMigrate 新表
## 4. 非目标(YAGNI
本次**不做**
- 恢复到自定义路径/自定义数据库连接(路径穿越、鉴权面大,留作 v2)
- 恢复干运行(dry-run
- Agent 加密恢复(与 Agent 加密备份同策略:加密密钥不下发到 Agent)
- 跨节点恢复(把 Agent A 的备份恢复到 Agent B)—— 任务绑定哪个节点就在哪个节点恢复
## 5. 测试策略
### 后端
- `RestoreService.Start`:本机任务 → 走本地分支;远程任务 → 入队 `AgentCommand`
- `RestoreRecordRepository`CRUD + 列表筛选
- `Agent Executor.ExecuteRestore`mock HTTP client + stub runner
### 前端
- 通过 `tsc --noEmit` 保证类型安全
- 新增的 Dialog/Drawer/Page 至少跑通渲染(现有测试框架 vitest)
### 双 review 清单
- `go build ./...` / `go vet ./...` / `go test ./... -count=1 -race` 全绿
- `npm run build`(前端) 通过
- CLAUDE.md 规范:所有错误必须处理、中文 commit、不引入新 UI 库
- 修改范围对照讨论:B1 节点选择器 ✅、恢复底层重构 ✅
## 6. 实施顺序
1. RestoreRecord model + migration + repository
2. AgentCommand 新命令类型常量
3. RestoreService(本地执行 + 节点路由)
4. AgentService + HTTPGetRestoreSpec / UpdateRestoreRecord
5. Agent client + executorExecuteRestore
6. Master HTTPRestoreHandler + router
7. app.go 依赖注入
8. 前端:types/services → 节点选择器 → 确认对话框 → 日志抽屉 → 列表页 → 路由 + 菜单
9. 修 B2`handleRestore` 改为 Message.success + 跳转)
10. 单元测试
11. 双 reviewbuild/vet/test + tsc