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

+173 -13
View File
@@ -1,8 +1,13 @@
package handlers
import (
"bytes"
"context"
"fmt"
"net/http"
"os/exec"
"strconv"
"time"
"ezops/internal/models"
@@ -10,6 +15,50 @@ import (
"gorm.io/gorm"
)
// runLocal executes a script or shell command locally on the AIOps host and
// returns the combined stdout+stderr output, the process exit code (0 = success)
// and the wall-clock duration in milliseconds. A timeout prevents runaway jobs.
func runLocal(scriptType, content string, timeoutSec int) (output string, exitCode int, durationMs int) {
start := time.Now()
timeout := time.Duration(timeoutSec) * time.Second
if timeout <= 0 {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd *exec.Cmd
switch scriptType {
case "python":
cmd = exec.CommandContext(ctx, "python3", "-c", content)
default: // shell / command / "" → run via bash
cmd = exec.CommandContext(ctx, "bash", "-c", content)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
durationMs = int(time.Since(start).Milliseconds())
output = out.String()
if ctx.Err() == context.DeadlineExceeded {
output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec)
return output, -1, durationMs
}
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
output += "\n" + err.Error()
}
return output, exitCode, durationMs
}
return output, 0, durationMs
}
// AutomationHandler 自动化运维处理器
type AutomationHandler struct {
db *gorm.DB
@@ -278,15 +327,28 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
return
}
// Resolve the content to execute. For type=script, load the linked Script.
content := job.Content
scriptType := "shell"
if job.Type == "script" && job.ScriptID != nil {
var script models.Script
if err := h.db.First(&script, *job.ScriptID).Error; err == nil {
content = script.Content
scriptType = script.Type
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
}
}
now := time.Now()
execution := models.TaskExecution{
TaskType: "cron",
TaskID: uint(id),
TaskName: job.Name,
Status: "pending",
Status: "running",
TargetType: job.TargetType,
Targets: job.Targets,
StartedAt: &now,
}
if err := h.db.Create(&execution).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{
Code: 500,
@@ -295,9 +357,46 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
return
}
// Execute locally on the AIOps host.
output, exitCode, duration := runLocal(scriptType, content, job.Timeout)
finishedAt := time.Now()
status := "success"
if exitCode != 0 {
status = "failed"
}
h.db.Model(&execution).Updates(map[string]interface{}{
"status": status,
"output": output,
"duration": duration,
"finished_at": finishedAt,
})
// Update cron job statistics.
statsUpdates := map[string]interface{}{
"last_run_at": now,
"last_status": status,
"run_count": job.RunCount + 1,
}
if status == "success" {
statsUpdates["success_count"] = job.SuccessCount + 1
} else {
statsUpdates["fail_count"] = job.FailCount + 1
}
h.db.Model(&job).Updates(statsUpdates)
execution.Output = output
execution.Status = status
execution.Duration = duration
execution.FinishedAt = &finishedAt
msg := "执行成功"
if status == "failed" {
msg = "执行失败"
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "执行中",
Message: msg,
Data: execution,
})
}
@@ -520,23 +619,19 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
return
}
// The request body is optional — when omitted we execute locally.
var req models.ExecuteScriptRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{
Code: 400,
Message: "请求参数错误: " + err.Error(),
})
return
}
_ = c.ShouldBindJSON(&req)
now := time.Now()
execution := models.TaskExecution{
TaskType: "script",
TaskID: uint(id),
TaskName: script.Name,
Status: "pending",
Status: "running",
TargetType: req.TargetType,
StartedAt: &now,
}
if err := h.db.Create(&execution).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{
Code: 500,
@@ -545,9 +640,74 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
return
}
// Execute locally on the AIOps host.
output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout)
finishedAt := time.Now()
status := "success"
if exitCode != 0 {
status = "failed"
}
h.db.Model(&execution).Updates(map[string]interface{}{
"status": status,
"output": output,
"duration": duration,
"finished_at": finishedAt,
})
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
execution.Output = output
execution.Status = status
execution.Duration = duration
execution.FinishedAt = &finishedAt
msg := "执行成功"
if status == "failed" {
msg = "执行失败"
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "执行中",
Message: msg,
Data: execution,
})
}
// ListExecutions 获取任务执行记录列表
// @Summary 获取任务执行记录
// @Description 获取脚本/定时任务的执行历史记录(支持 task_type / task_id 筛选)
// @Tags 自动化运维
// @Produce json
// @Security Bearer
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param task_type query string false "任务类型 (cron/script)"
// @Param task_id query string false "任务ID"
// @Success 200 {object} models.Response{data=models.ListResponse}
// @Router /task-executions [get]
func (h *AutomationHandler) ListExecutions(c *gin.Context) {
var query models.PageQuery
c.ShouldBindQuery(&query)
db := h.db.Model(&models.TaskExecution{})
if taskType := c.Query("task_type"); taskType != "" {
db = db.Where("task_type = ?", taskType)
}
if taskID := c.Query("task_id"); taskID != "" {
db = db.Where("task_id = ?", taskID)
}
var total int64
db.Count(&total)
var execs []models.TaskExecution
db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&execs)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: models.ListResponse{
List: execs,
Total: total,
},
})
}