feat: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换pending空壳为本地执行,采集stdout/stderr,更新统计;修复CronJobManager/Automation字段名不匹配(status→enabled,cron→cron_expr,command→content,language→type)

This commit is contained in:
attacker committed 2026-06-14 22:11:18 +08:00
1 parent 85bc2a8e05
commit a4fd5fc178
19 files changed
+2715 -114

No files matched your search

+58 -1
View File
@@ -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 {
+149
View File
@@ -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)
}
}
+140
View File
@@ -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
}