diff --git a/.gitignore b/.gitignore index ac8d584..bfeb8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ web/node_modules/ web/dist/ -server/bin/ \ No newline at end of file +server/bin/ +.claude/ \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md b/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md new file mode 100644 index 0000000..2545e9c --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-cluster-restore-and-node-selector-design.md @@ -0,0 +1,288 @@ +# 设计文档:集群感知恢复功能 & 任务节点选择器 + +- 日期: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 → 202,body: {restoreRecordId} +GET /api/restore/records → 列表(支持 ?taskId, ?status 筛选) +GET /api/restore/records/:id → 详情(含 logContent) +GET /api/restore/records/:id/logs/stream → SSE(复用 LogHub,sequence 事件协议) +``` + +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 +