package handlers import ( "context" "encoding/json" "fmt" "net/http" "os" "os/exec" "regexp" "strings" "time" "ezops/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // AnsibleHandler Ansible 自动化处理器 // Logan.Li type AnsibleHandler struct { db *gorm.DB } func NewAnsibleHandler(db *gorm.DB) *AnsibleHandler { return &AnsibleHandler{db: db} } // ==================== Playbook ==================== func (h *AnsibleHandler) ListPlaybooks(c *gin.Context) { var query models.PageQuery c.ShouldBindQuery(&query) db := h.db.Model(&models.AnsiblePlaybook{}) if name := c.Query("name"); name != "" { db = db.Where("name LIKE ?", "%"+name+"%") } if cat := c.Query("category"); cat != "" { db = db.Where("category = ?", cat) } var total int64 db.Count(&total) var items []models.AnsiblePlaybook db.Order("updated_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&items) c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: total}}) } func (h *AnsibleHandler) GetPlaybook(c *gin.Context) { var item models.AnsiblePlaybook if err := h.db.First(&item, c.Param("id")); err.Error != nil { c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Playbook 不存在"}) return } c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } func (h *AnsibleHandler) CreatePlaybook(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Content string `json:"content" binding:"required"` Tags string `json:"tags"` Category string `json:"category"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) return } username := c.GetString("username") item := models.AnsiblePlaybook{ Name: req.Name, Description: req.Description, Content: req.Content, Tags: req.Tags, Category: req.Category, CreatedBy: username, } if err := h.db.Create(&item); err.Error != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) return } c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } func (h *AnsibleHandler) UpdatePlaybook(c *gin.Context) { id := c.Param("id") var item models.AnsiblePlaybook if err := h.db.First(&item, id); err.Error != nil { c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) return } var req struct { Name *string `json:"name"` Description *string `json:"description"` Content *string `json:"content"` Tags *string `json:"tags"` Category *string `json:"category"` } c.ShouldBindJSON(&req) updates := map[string]interface{}{} if req.Name != nil { updates["name"] = *req.Name } if req.Description != nil { updates["description"] = *req.Description } if req.Content != nil { updates["content"] = *req.Content } if req.Tags != nil { updates["tags"] = *req.Tags } if req.Category != nil { updates["category"] = *req.Category } h.db.Model(&item).Updates(updates) h.db.First(&item, id) c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } func (h *AnsibleHandler) DeletePlaybook(c *gin.Context) { id := c.Param("id") if err := h.db.Delete(&models.AnsiblePlaybook{}, id); err.Error != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) return } c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) } // BatchDeletePlaybooks 批量删除 Playbook // Logan.Li func (h *AnsibleHandler) BatchDeletePlaybooks(c *gin.Context) { var req models.IDsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"}) return } if len(req.IDs) == 0 { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "ids 不能为空"}) return } if err := h.db.Delete(&models.AnsiblePlaybook{}, req.IDs).Error; err != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) return } c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) } // ==================== Job ==================== func (h *AnsibleHandler) ListJobs(c *gin.Context) { var query models.PageQuery c.ShouldBindQuery(&query) db := h.db.Model(&models.AnsibleJob{}) if status := c.Query("status"); status != "" { db = db.Where("status = ?", status) } if pbID := c.Query("playbook_id"); pbID != "" { db = db.Where("playbook_id = ?", pbID) } var total int64 db.Count(&total) var items []models.AnsibleJob db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&items) c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: total}}) } func (h *AnsibleHandler) GetJob(c *gin.Context) { var item models.AnsibleJob if err := h.db.First(&item, c.Param("id")); err.Error != nil { c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) return } c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } // ExecutePlaybook 执行 Playbook — 调用 ansible-playbook 真实执行 // Logan.Li func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) { pbID := c.Param("id") var playbook models.AnsiblePlaybook if err := h.db.First(&playbook, pbID); err.Error != nil { c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Playbook 不存在"}) return } var req struct { TargetHosts []string `json:"target_hosts"` Variables map[string]interface{} `json:"variables"` } c.ShouldBindJSON(&req) username := c.GetString("username") hostsJSON, _ := json.Marshal(req.TargetHosts) varsJSON, _ := json.Marshal(req.Variables) now := time.Now() job := models.AnsibleJob{ PlaybookID: playbook.ID, PlaybookName: playbook.Name, TargetHosts: string(hostsJSON), Variables: string(varsJSON), TriggeredBy: username, StartedAt: &now, } // 尝试调用真实的 ansible-playbook content := playbook.Content if content != "" { tmpFile, err := os.CreateTemp("", "ansible-playbook-*.yml") if err == nil { defer os.Remove(tmpFile.Name()) tmpFile.WriteString(content) tmpFile.Close() ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) defer cancel() args := []string{tmpFile.Name()} // 根据目标主机决定连接方式 if len(req.TargetHosts) > 0 { // 查询目标主机的 SSH 信息,生成临时 inventory var servers []models.Server h.db.Where("hostname IN ? OR ip IN ?", req.TargetHosts, req.TargetHosts).Find(&servers) if len(servers) > 0 { invFile, invErr := os.CreateTemp("", "ansible-inventory-*.ini") if invErr == nil { defer os.Remove(invFile.Name()) invFile.WriteString("[targets]\n") for _, s := range servers { sshUser := s.Username if sshUser == "" { sshUser = "root" } invFile.WriteString(fmt.Sprintf("%s ansible_host=%s ansible_user=%s ansible_port=%d ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'\n", s.Hostname, s.IP, sshUser, s.Port)) } invFile.Close() args = append(args, "--inventory", invFile.Name()) } } } else { args = append(args, "--connection=local") } // 构建 extra-vars var extraVars []string for k, v := range req.Variables { extraVars = append(extraVars, fmt.Sprintf("%s=%v", k, v)) } if len(extraVars) > 0 { args = append(args, "--extra-vars", strings.Join(extraVars, " ")) } cmd := exec.CommandContext(ctx, "ansible-playbook", args...) output, runErr := cmd.CombinedOutput() job.Duration = int64(time.Since(now).Milliseconds()) if runErr != nil { job.Status = "failed" job.Output = fmt.Sprintf("ansible-playbook 执行失败: %v\n\n%s", runErr, string(output)) } else { job.Status = "success" job.Output = string(output) } job.CompletedAt = timePtr(time.Now()) h.db.Create(&job) c.JSON(http.StatusOK, models.Response{Code: 200, Data: job}) return } } // Fallback: ansible-playbook 不可用或无内容 → 返回错误 job.Status = "failed" job.Output = "无法执行:ansible-playbook 未安装或 Playbook 内容为空。\n请在 AIOps 主机执行:apt install ansible / yum install ansible" job.Duration = 0 job.CompletedAt = timePtr(time.Now()) h.db.Create(&job) c.JSON(http.StatusOK, models.Response{Code: 200, Data: job}) } func timePtr(t time.Time) *time.Time { return &t } // ==================== Inventory ==================== func (h *AnsibleHandler) ListInventories(c *gin.Context) { var items []models.AnsibleInventory h.db.Order("updated_at DESC").Find(&items) c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}}) } func (h *AnsibleHandler) CreateInventory(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Hosts interface{} `json:"hosts"` // 数组 } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) return } hostsJSON, _ := json.Marshal(req.Hosts) item := models.AnsibleInventory{ Name: req.Name, Description: req.Description, Hosts: string(hostsJSON), CreatedBy: c.GetString("username"), } h.db.Create(&item) c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } func (h *AnsibleHandler) UpdateInventory(c *gin.Context) { id := c.Param("id") var item models.AnsibleInventory if err := h.db.First(&item, id); err.Error != nil { c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) return } var req struct { Name *string `json:"name"` Description *string `json:"description"` Hosts interface{} `json:"hosts"` } c.ShouldBindJSON(&req) updates := map[string]interface{}{} if req.Name != nil { updates["name"] = *req.Name } if req.Description != nil { updates["description"] = *req.Description } if req.Hosts != nil { hostsJSON, _ := json.Marshal(req.Hosts) updates["hosts"] = string(hostsJSON) } h.db.Model(&item).Updates(updates) h.db.First(&item, id) c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } func (h *AnsibleHandler) DeleteInventory(c *gin.Context) { if err := h.db.Delete(&models.AnsibleInventory{}, c.Param("id")); err.Error != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) return } c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) } // ==================== 主机匹配 ==================== // MatchHostsRequest 主机匹配请求 type MatchHostsRequest struct { Pattern string `json:"pattern" form:"pattern"` // 匹配模式(正则或前缀) MatchType string `json:"match_type" form:"match_type"` // "regex", "prefix", "contains" Page int `json:"page" form:"page"` PageSize int `json:"page_size" form:"page_size"` } // MatchHosts 预览匹配的主机列表(从资产服务器表查询)// Logan.Li // GET /api/v1/ansible/match-hosts?pattern=web*&match_type=prefix func (h *AnsibleHandler) MatchHosts(c *gin.Context) { var req MatchHostsRequest c.ShouldBindQuery(&req) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 200 { req.PageSize = 50 } query := h.db.Model(&models.Server{}) if req.Pattern != "" { switch req.MatchType { case "regex": // 编译正则表达式 re, err := regexp.Compile(req.Pattern) if err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "正则表达式错误: " + err.Error()}) return } // MySQL 使用 REGEXP 关键字 query = query.Where("hostname REGEXP ? OR ip REGEXP ? OR tags REGEXP ?", req.Pattern, req.Pattern, req.Pattern) // 对于复杂的正则,MySQL 可能不支持;先查全部再 Go 过滤 if !isMySQLSafeRegex(req.Pattern) { var all []models.Server h.db.Find(&all) var matched []models.Server for _, s := range all { if re.MatchString(s.Hostname) || re.MatchString(s.IP) || re.MatchString(s.Tags) { matched = append(matched, s) } } total := len(matched) start := (req.Page - 1) * req.PageSize end := start + req.PageSize if start > total { start = total } if end > total { end = total } result := matched[start:end] for i := range result { result[i].HasAuth = result[i].Password != "" || result[i].SSHKey != "" } c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: result, Total: int64(total)}}) return } case "prefix": query = query.Where("hostname LIKE ? OR ip LIKE ?", req.Pattern+"%", req.Pattern+"%") default: // "contains" query = query.Where("hostname LIKE ? OR ip LIKE ? OR tags LIKE ?", "%"+req.Pattern+"%", "%"+req.Pattern+"%", "%"+req.Pattern+"%") } } var total int64 query.Count(&total) var items []models.Server offset := (req.Page - 1) * req.PageSize query.Offset(offset).Limit(req.PageSize).Order("hostname").Find(&items) // 补充 HasAuth 字段 for i := range items { items[i].HasAuth = items[i].Password != "" || items[i].SSHKey != "" } c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: total}}) } // isMySQLSafeRegex 判断正则表达式是否可安全用于 MySQL REGEXP func isMySQLSafeRegex(pat string) bool { // MySQL REGEXP 支持基本正则,但如果包含复杂断言或 Unicode 则不适合 unsafe := []string{`\b`, `(?=`, `(?!`, `(?<=`, `(?