mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-07 05:02:51 +08:00
基础修复: - 新增节点离线检测:每 15s 扫描,超 45s 未心跳的远程节点自动置离线 - 节点删除前检查关联任务,避免孤立备份任务 - BackupTaskRepository 新增 CountByNodeID/ListByNodeID Master 端 Agent 协议: - 新增 AgentCommand 模型与命令队列仓储(pending/dispatched/succeeded/failed/timeout) - 新增 AgentService:任务下发、命令轮询、结果回收、超时扫描 - 新增专用 Agent HTTP API(X-Agent-Token 认证): /api/agent/heartbeat /api/agent/commands/poll /api/agent/commands/:id/result /api/agent/tasks/:id /api/agent/records/:id - BackupExecutionService 支持 node 路由:task.NodeID 指向远程节点时自动入队派发 Agent CLI(backupx agent 子命令): - 配置:YAML 文件 / 环境变量 / CLI 参数,优先级 CLI > 文件 > 环境 - 心跳循环 + 命令轮询循环 + 优雅退出 - 本地复用 BackupRunner 与 storage registry 执行备份并直接上传 - 支持 run_task 和 list_dir 两种命令 远程目录浏览: - NodeService 支持通过 Agent RPC 列出远程节点目录(15s 超时) 前端: - NodesPage 添加节点后展示 Agent 启动命令和环境变量配置 文档: - README 中英文重写"多节点集群"章节,含架构图、步骤、限制、CLI 参考
209 lines
6.4 KiB
Go
209 lines
6.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MasterClient 是 Agent 调用 Master HTTP API 的封装。
|
|
type MasterClient struct {
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewMasterClient 构造 Master 客户端。
|
|
func NewMasterClient(baseURL, token string, insecureTLS bool) *MasterClient {
|
|
transport := &http.Transport{}
|
|
if insecureTLS {
|
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
return &MasterClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
httpClient: &http.Client{
|
|
Timeout: 120 * time.Second,
|
|
Transport: transport,
|
|
},
|
|
}
|
|
}
|
|
|
|
// HeartbeatRequest Agent 上报心跳的请求
|
|
type HeartbeatRequest struct {
|
|
Token string `json:"token"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
IPAddress string `json:"ipAddress,omitempty"`
|
|
AgentVersion string `json:"agentVersion,omitempty"`
|
|
OS string `json:"os,omitempty"`
|
|
Arch string `json:"arch,omitempty"`
|
|
}
|
|
|
|
// HeartbeatResponse Master 返回的心跳响应
|
|
type HeartbeatResponse struct {
|
|
Status string `json:"status"`
|
|
NodeID uint `json:"nodeId"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Heartbeat 上报心跳并获取节点元信息
|
|
func (c *MasterClient) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
|
|
var resp HeartbeatResponse
|
|
if err := c.do(ctx, http.MethodPost, "/api/agent/heartbeat", req, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// CommandPayload 与 service.AgentCommandPayload 对齐
|
|
type CommandPayload struct {
|
|
ID uint `json:"id"`
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
// PollCommandResponse 轮询响应:无命令时 Command 为 nil
|
|
type PollCommandResponse struct {
|
|
Command *CommandPayload `json:"command"`
|
|
}
|
|
|
|
// PollCommand 拉取下一条待执行命令
|
|
func (c *MasterClient) PollCommand(ctx context.Context) (*CommandPayload, error) {
|
|
var resp PollCommandResponse
|
|
if err := c.do(ctx, http.MethodPost, "/api/agent/commands/poll", nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Command, nil
|
|
}
|
|
|
|
// SubmitCommandResult 上报命令执行结果
|
|
func (c *MasterClient) SubmitCommandResult(ctx context.Context, cmdID uint, success bool, errorMsg string, result any) error {
|
|
var resultJSON json.RawMessage
|
|
if result != nil {
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal result: %w", err)
|
|
}
|
|
resultJSON = data
|
|
}
|
|
payload := map[string]any{
|
|
"success": success,
|
|
"errorMessage": errorMsg,
|
|
}
|
|
if resultJSON != nil {
|
|
payload["result"] = resultJSON
|
|
}
|
|
path := fmt.Sprintf("/api/agent/commands/%d/result", cmdID)
|
|
return c.do(ctx, http.MethodPost, path, payload, nil)
|
|
}
|
|
|
|
// TaskSpec 与 service.AgentTaskSpec 对齐
|
|
type TaskSpec struct {
|
|
TaskID uint `json:"taskId"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
SourcePath string `json:"sourcePath"`
|
|
SourcePaths string `json:"sourcePaths"`
|
|
ExcludePatterns string `json:"excludePatterns"`
|
|
DBHost string `json:"dbHost"`
|
|
DBPort int `json:"dbPort"`
|
|
DBUser string `json:"dbUser"`
|
|
DBPassword string `json:"dbPassword"`
|
|
DBName string `json:"dbName"`
|
|
DBPath string `json:"dbPath"`
|
|
ExtraConfig string `json:"extraConfig"`
|
|
Compression string `json:"compression"`
|
|
Encrypt bool `json:"encrypt"`
|
|
StorageTargets []StorageTargetConfig `json:"storageTargets"`
|
|
}
|
|
|
|
// StorageTargetConfig 与 service.AgentStorageTargetConfig 对齐
|
|
type StorageTargetConfig struct {
|
|
ID uint `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Config json.RawMessage `json:"config"`
|
|
}
|
|
|
|
// GetTaskSpec 拉取任务规格
|
|
func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec, error) {
|
|
var spec TaskSpec
|
|
path := fmt.Sprintf("/api/agent/tasks/%d", taskID)
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
|
|
return nil, err
|
|
}
|
|
return &spec, nil
|
|
}
|
|
|
|
// RecordUpdate 与 service.AgentRecordUpdate 对齐
|
|
type RecordUpdate struct {
|
|
Status string `json:"status,omitempty"`
|
|
FileName string `json:"fileName,omitempty"`
|
|
FileSize int64 `json:"fileSize,omitempty"`
|
|
Checksum string `json:"checksum,omitempty"`
|
|
StoragePath string `json:"storagePath,omitempty"`
|
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
|
LogAppend string `json:"logAppend,omitempty"`
|
|
}
|
|
|
|
// UpdateRecord 上报备份记录的状态/日志
|
|
func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update RecordUpdate) error {
|
|
path := fmt.Sprintf("/api/agent/records/%d", recordID)
|
|
return c.do(ctx, http.MethodPost, path, update, nil)
|
|
}
|
|
|
|
// do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
|
|
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
reqBody = bytes.NewReader(data)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("X-Agent-Token", c.token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read response: %w", err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, string(data))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
// BackupX API 统一封装成 {code, data, message} 形式,需要解出 data 字段
|
|
var envelope struct {
|
|
Code string `json:"code"`
|
|
Data json.RawMessage `json:"data"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(data, &envelope); err == nil && envelope.Data != nil {
|
|
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
|
return fmt.Errorf("decode data: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
// 兼容直接返回对象的情况
|
|
return json.Unmarshal(data, out)
|
|
}
|