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) } // RestoreSpec 与 service.AgentRestoreSpec 对齐 type RestoreSpec struct { RestoreRecordID uint `json:"restoreRecordId"` BackupRecordID uint `json:"backupRecordId"` TaskID uint `json:"taskId"` TaskName string `json:"taskName"` Type string `json:"type"` SourcePath string `json:"sourcePath,omitempty"` SourcePaths []string `json:"sourcePaths,omitempty"` DBHost string `json:"dbHost,omitempty"` DBPort int `json:"dbPort,omitempty"` DBUser string `json:"dbUser,omitempty"` DBPassword string `json:"dbPassword,omitempty"` DBName string `json:"dbName,omitempty"` DBPath string `json:"dbPath,omitempty"` ExtraConfig string `json:"extraConfig,omitempty"` Compression string `json:"compression"` Encrypt bool `json:"encrypt"` Storage StorageTargetConfig `json:"storage"` StoragePath string `json:"storagePath"` FileName string `json:"fileName"` } // RestoreUpdate 与 service.AgentRestoreUpdate 对齐 type RestoreUpdate struct { Status string `json:"status,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` LogAppend string `json:"logAppend,omitempty"` } // GetRestoreSpec 拉取恢复规格 func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint) (*RestoreSpec, error) { var spec RestoreSpec path := fmt.Sprintf("/api/agent/restores/%d/spec", restoreRecordID) if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil { return nil, err } return &spec, nil } // UpdateRestore 上报恢复记录的状态/日志 func (c *MasterClient) UpdateRestore(ctx context.Context, restoreRecordID uint, update RestoreUpdate) error { path := fmt.Sprintf("/api/agent/restores/%d", restoreRecordID) 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) }