feat: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换pending空壳为本地执行,采集stdout/stderr,更新统计;修复CronJobManager/Automation字段名不匹配(status→enabled,cron→cron_expr,command→content,language→type)
This commit is contained in:
1 parent
85bc2a8e05
commit
a4fd5fc178
19 files changed
+2715
-114
No files matched your search
+58
-1
@@ -18,6 +18,7 @@ import (
|
||||
"ezops/agent/collector"
|
||||
"ezops/agent/model"
|
||||
"ezops/agent/reporter"
|
||||
"ezops/agent/task"
|
||||
)
|
||||
|
||||
// 编译时可注入的版本信息
|
||||
@@ -65,6 +66,10 @@ func main() {
|
||||
portCollector := collector.NewPortCollector()
|
||||
rep := reporter.New(*server, *token, logger)
|
||||
|
||||
// 初始化任务执行器与任务客户端(与 reporter 复用相同的 server/token/hostname)
|
||||
taskExecutor := task.New(logger)
|
||||
taskClient := task.NewClient(*server, *token, hostname, logger)
|
||||
|
||||
// 监听 SIGINT / SIGTERM,支持优雅退出
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -111,7 +116,59 @@ func main() {
|
||||
report.CPU.UsagePercent, report.Memory.UsagePercent, len(report.Disks), len(report.Ports))
|
||||
}
|
||||
|
||||
// 启动后立即执行一次,再按 ticker 周期执行
|
||||
// pollAndExecute 执行单次任务轮询:拉取待执行任务 → 逐个执行 → 回传结果。
|
||||
// 网络错误只记录日志,不 panic,下一个周期继续重试。
|
||||
pollAndExecute := func() {
|
||||
tasks, err := taskClient.Poll(ctx)
|
||||
if err != nil {
|
||||
// 上下文已取消(Agent 退出中)属于正常情况,不输出告警
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Printf("任务轮询失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return // 暂无任务,静默返回
|
||||
}
|
||||
logger.Printf("拉取到 %d 个待执行任务", len(tasks))
|
||||
|
||||
for _, t := range tasks {
|
||||
// Agent 退出时跳过后续任务,避免退出延迟
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
result := taskExecutor.ExecuteTask(ctx, t)
|
||||
if err := taskClient.Submit(ctx, result); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Printf("任务 %s 结果回传失败: %v", t.TaskID, err)
|
||||
} else {
|
||||
logger.Printf("任务 %s 结果回传成功 status=%s", t.TaskID, result.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动任务轮询协程:每 5 秒轮询一次,与指标采集并行运行。
|
||||
// 使用独立的 5 秒固定间隔,不受 -interval 影响。
|
||||
const taskPollInterval = 5 * time.Second
|
||||
go func() {
|
||||
// 启动后立即轮询一次,再按固定周期执行
|
||||
pollAndExecute()
|
||||
ticker := time.NewTicker(taskPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pollAndExecute()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 主协程:启动后立即执行一次指标采集,再按 ticker 周期执行
|
||||
collectAndReport()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package task 的 client.go 实现与后端任务接口的 HTTP 交互。
|
||||
// 涵盖拉取待执行任务(poll)与回传执行结果(submit result)。
|
||||
// 仅依赖 Go 标准库,失败时返回错误,由调用方决定重试策略。
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pollPath = "/api/v1/agent/tasks/poll" // 任务轮询接口
|
||||
resultPathFmt = "/api/v1/agent/tasks/%s/result" // 结果回传接口(含 task_id 占位)
|
||||
httpTimeout = 10 * time.Second // 单次 HTTP 请求超时
|
||||
clientUserAgent = "aiops-agent/1.0" // 标识 Agent 版本
|
||||
)
|
||||
|
||||
// Client 负责与后端任务接口通信。
|
||||
type Client struct {
|
||||
server string
|
||||
token string
|
||||
hostname string
|
||||
httpClient *http.Client
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewClient 创建任务客户端实例。
|
||||
// - server: AIOps 后端地址(如 http://localhost:8080)
|
||||
// - token: Bearer 认证令牌
|
||||
// - hostname: 本机主机名,用于后端筛选该主机待执行的任务
|
||||
func NewClient(server, token, hostname string, logger *log.Logger) *Client {
|
||||
return &Client{
|
||||
server: strings.TrimRight(server, "/"),
|
||||
token: token,
|
||||
hostname: hostname,
|
||||
httpClient: &http.Client{Timeout: httpTimeout},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Poll 拉取当前主机待执行的任务列表。
|
||||
// 返回空切片表示暂无任务(后端返回空数组或 HTTP 204)。
|
||||
func (c *Client) Poll(ctx context.Context) ([]Task, error) {
|
||||
// 构造带 hostname 查询参数的 URL
|
||||
u := c.server + pollPath + "?hostname=" + url.QueryEscape(c.hostname)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造轮询请求失败: %w", err)
|
||||
}
|
||||
c.attachHeaders(req)
|
||||
|
||||
// 上下文已取消则直接返回,避免无意义请求
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("轮询请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 204 No Content 或 200 空体均视为无任务
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("轮询响应异常: HTTP %d, body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取轮询响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 空响应体视为无任务
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []Task `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapper); err != nil {
|
||||
// 兼容裸数组格式
|
||||
var tasks []Task
|
||||
if err2 := json.Unmarshal(body, &tasks); err2 != nil {
|
||||
return nil, fmt.Errorf("解析轮询响应失败: %w", err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
return wrapper.Data, nil
|
||||
}
|
||||
|
||||
// Submit 将任务执行结果回传至后端。
|
||||
func (c *Client) Submit(ctx context.Context, res Result) error {
|
||||
payload, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化任务结果失败: %w", err)
|
||||
}
|
||||
|
||||
u := c.server + fmt.Sprintf(resultPathFmt, url.PathEscape(res.TaskID))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("构造回传请求失败: %w", err)
|
||||
}
|
||||
c.attachHeaders(req)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("回传请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("回传响应异常: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachHeaders 统一设置公共请求头(鉴权、内容类型、UA)。
|
||||
func (c *Client) attachHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", clientUserAgent)
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package task 实现 AIOps Agent 的任务拉取与执行能力。
|
||||
// Agent 通过轮询接口从后端获取待执行任务(命令/脚本),本地执行后回传结果。
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 任务类型常量
|
||||
const (
|
||||
TypeCommand = "command" // 单条 shell 命令
|
||||
TypeScript = "script" // 多行 bash 脚本
|
||||
)
|
||||
|
||||
// Task 表示从后端拉取到的待执行任务。
|
||||
type Task struct {
|
||||
TaskID string `json:"task_id"` // 任务唯一标识,回传结果时使用
|
||||
Type string `json:"type"` // 任务类型:command / script
|
||||
Command string `json:"command"` // 命令内容(command 时为单条命令,script 时为脚本正文)
|
||||
Timeout int `json:"timeout"` // 执行超时,单位:秒;<=0 表示不限制
|
||||
}
|
||||
|
||||
// Result 表示任务执行结果,用于回传后端。
|
||||
type Result struct {
|
||||
TaskID string `json:"task_id"` // 对应的任务 ID
|
||||
ExitCode int `json:"exit_code"` // 进程退出码;超时/启动失败时为 -1
|
||||
Result string `json:"result"` // 合并后的 stdout+stderr 输出
|
||||
Status string `json:"status"` // 执行状态:success / failed / timeout
|
||||
}
|
||||
|
||||
// Executor 负责本地执行任务并产出结果。
|
||||
type Executor struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// New 创建 Executor 实例。
|
||||
func New(logger *log.Logger) *Executor {
|
||||
return &Executor{logger: logger}
|
||||
}
|
||||
|
||||
// ExecuteTask 执行单个任务,返回执行结果。
|
||||
// 根据任务类型选择执行方式:
|
||||
// - command: 通过 "sh -c <command>" 执行单条命令
|
||||
// - script: 通过 "bash" 以标准输入喂入脚本正文执行
|
||||
//
|
||||
// 超时控制通过 context 实现;网络/信号导致的取消不会 panic。
|
||||
// 超时或执行失败均会返回 Result,不返回 error,便于调用方统一回传。
|
||||
func (e *Executor) ExecuteTask(ctx context.Context, t Task) Result {
|
||||
res := Result{TaskID: t.TaskID}
|
||||
|
||||
// 构造子进程:根据任务类型选择解释器
|
||||
var cmd *exec.Cmd
|
||||
switch t.Type {
|
||||
case TypeCommand:
|
||||
// 单条命令:交给 /bin/sh -c 解释执行
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", t.Command)
|
||||
case TypeScript:
|
||||
// 脚本:通过 bash 标准输入执行,便于支持多行与 heredoc 等语法
|
||||
cmd = exec.CommandContext(ctx, "bash")
|
||||
cmd.Stdin = bytes.NewReader([]byte(t.Command))
|
||||
default:
|
||||
// 未知任务类型,直接标记失败,避免误执行
|
||||
res.ExitCode = -1
|
||||
res.Status = "failed"
|
||||
res.Result = fmt.Sprintf("未知的任务类型: %q", t.Type)
|
||||
e.logger.Printf("任务 %s 类型非法: %q", t.TaskID, t.Type)
|
||||
return res
|
||||
}
|
||||
|
||||
// 合并 stdout 与 stderr,输出顺序与终端一致,便于排查问题
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
// 设置执行超时上下文(若任务指定了有效 timeout)
|
||||
execCtx, cancel := e.withTimeout(ctx, t.Timeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
err := cmd.Run()
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
// 优先判定超时:Run 返回的 deadline exceeded 与 ctx.Err() 一起识别
|
||||
if execCtx.Err() == context.DeadlineExceeded {
|
||||
res.ExitCode = -1
|
||||
res.Status = "timeout"
|
||||
res.Result = fmt.Sprintf("任务执行超时(%ds),已输出:\n%s", t.Timeout, buf.String())
|
||||
e.logger.Printf("任务 %s 执行超时(%ds)", t.TaskID, t.Timeout)
|
||||
return res
|
||||
}
|
||||
|
||||
// 正常退出码提取
|
||||
res.ExitCode = exitCode(err)
|
||||
res.Result = buf.String()
|
||||
|
||||
// 根据退出码判定状态
|
||||
if res.ExitCode == 0 {
|
||||
res.Status = "success"
|
||||
e.logger.Printf("任务 %s 执行成功 exit=0 耗时=%s 输出=%dB",
|
||||
t.TaskID, elapsed, buf.Len())
|
||||
} else {
|
||||
res.Status = "failed"
|
||||
e.logger.Printf("任务 %s 执行失败 exit=%d 耗时=%s 输出=%dB",
|
||||
t.TaskID, res.ExitCode, elapsed, buf.Len())
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// withTimeout 在父上下文基础上叠加超时控制。
|
||||
// 当 timeout <= 0 时直接返回父上下文,不做超时限制。
|
||||
// 返回的取消函数保证安全可调用(即使未创建新的超时上下文)。
|
||||
func (e *Executor) withTimeout(parent context.Context, timeout int) (context.Context, context.CancelFunc) {
|
||||
if timeout <= 0 {
|
||||
// 无超时要求:返回空取消函数,保持调用方代码统一
|
||||
return parent, func() {}
|
||||
}
|
||||
return context.WithTimeout(parent, time.Duration(timeout)*time.Second)
|
||||
}
|
||||
|
||||
// exitCode 从 exec.Run 返回的错误中提取退出码。
|
||||
// 成功时返回 0;无法识别的错误(如进程未启动)返回 -1。
|
||||
func exitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
// 尝试断言为 *exec.ExitError 以获取 ExitCode
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
// 其他类型错误(如二进制不存在)统一记为 -1
|
||||
return -1
|
||||
}
|
||||
Reference in new issue
Block a user