From f1ada99d42e4d629e80f442787f44650a769e8ab Mon Sep 17 00:00:00 2001 From: zhou2004jj <12854881+zhou2004jj@user.noreply.gitee.com> Date: Sun, 12 Apr 2026 18:28:40 +0800 Subject: [PATCH] feat: k8s-crd&&ansible fixed --- api/api/k8s/controller/k8scrd.go | 291 +++++++ api/api/k8s/dao/k8scrd.go | 65 ++ api/api/k8s/service/k8scrd.go | 387 +++++++++ api/api/task/controller/configansible.go | 124 +++ api/api/task/controller/taskansible.go | 425 +++++++--- api/api/task/dao/configansible.go | 69 ++ api/api/task/dao/taskansible.go | 188 ++++- api/api/task/model/configansible.go | 20 + api/api/task/model/taskansible.go | 48 +- api/api/task/model/taskansiblehistory.go | 43 + api/api/task/model/taskansibleview.go | 15 + api/api/task/service/configansible.go | 137 ++++ api/api/task/service/taskansible.go | 865 ++++++++++++++++---- api/pkg/db/migrate.go | 4 + api/router/k8s/k8s.go | 12 + api/router/task/task.go | 55 +- web/.env.dev | 1 + web/.env.prod | 1 + web/package.json | 2 +- web/src/api/k8s.js | 74 ++ web/src/api/task.js | 81 +- web/src/router/k8s.js | 6 + web/src/utils/request.js | 6 +- web/src/views/Home.vue | 14 + web/src/views/K8s/k8s-crd.vue | 824 +++++++++++++++++++ web/src/views/task/Job/AnsibleLogDialog.vue | 284 +++---- web/src/views/task/TaskAnsible.vue | 11 + web/vue.config.js | 12 +- 28 files changed, 3574 insertions(+), 490 deletions(-) create mode 100644 api/api/k8s/controller/k8scrd.go create mode 100644 api/api/k8s/dao/k8scrd.go create mode 100644 api/api/k8s/service/k8scrd.go create mode 100644 api/api/task/controller/configansible.go create mode 100644 api/api/task/dao/configansible.go create mode 100644 api/api/task/model/configansible.go create mode 100644 api/api/task/model/taskansiblehistory.go create mode 100644 api/api/task/model/taskansibleview.go create mode 100644 api/api/task/service/configansible.go create mode 100644 web/.env.dev create mode 100644 web/.env.prod create mode 100644 web/src/views/K8s/k8s-crd.vue diff --git a/api/api/k8s/controller/k8scrd.go b/api/api/k8s/controller/k8scrd.go new file mode 100644 index 00000000..249efe9f --- /dev/null +++ b/api/api/k8s/controller/k8scrd.go @@ -0,0 +1,291 @@ +package controller + +import ( + "net/http" + "strconv" + + "dodevops-api/api/k8s/service" + "dodevops-api/common/result" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type K8sCRDController struct { + service service.IK8sCRDService +} + +func NewK8sCRDController(db *gorm.DB) *K8sCRDController { + return &K8sCRDController{ + service: service.NewK8sCRDService(db), + } +} + +// GetCRDGroups 获取CRD API Group列表 +// @Summary 获取CRD API Group列表 +// @Description 获取指定集群中所有CRD所属的API Group +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Router /k8s/cluster/{id}/crds/groups [get] +func (ctrl *K8sCRDController) GetCRDGroups(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + groups, err := ctrl.service.GetCRDGroups(uint(clusterId)) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, groups) +} + +// GetCRDList 获取CRD列表 +// @Summary 获取CRD列表 +// @Description 获取指定集群中的所有CustomResourceDefinitions +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Router /k8s/cluster/{id}/crds [get] +func (ctrl *K8sCRDController) GetCRDList(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + params := map[string]string{} + for k, v := range c.Request.URL.Query() { + if len(v) > 0 { + params[k] = v[0] + } + } + + crds, err := ctrl.service.GetCRDList(uint(clusterId), params) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, crds) +} + +// GetCustomResourceList 获取自定义资源列表 +// @Summary 获取自定义资源列表 +// @Description 获取指定CRD的自定义资源(CR)列表 +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Param namespaceName path string true "命名空间名称" +// @Param crdName path string true "CRD名称 (如 prometheusrules.monitoring.coreos.com)" +// @Router /k8s/cluster/{id}/namespaces/{namespaceName}/crds/{crdName}/resources [get] +func (ctrl *K8sCRDController) GetCustomResourceList(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + namespaceName := c.Param("namespaceName") + crdName := c.Param("crdName") + + params := map[string]string{} + for k, v := range c.Request.URL.Query() { + if len(v) > 0 { + params[k] = v[0] + } + } + + resList, err := ctrl.service.GetCustomResourceList(uint(clusterId), namespaceName, crdName, params) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, resList) +} + +// GetCustomResourceDetail 获取自定义资源详情 +// @Summary 获取自定义资源详情 +// @Description 获取特定的自定义资源实例的详细信息 +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Param namespaceName path string true "命名空间名称" +// @Param crdName path string true "CRD名称" +// @Param crName path string true "CR名称" +// @Router /k8s/cluster/{id}/namespaces/{namespaceName}/crds/{crdName}/resources/{crName} [get] +func (ctrl *K8sCRDController) GetCustomResourceDetail(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + namespaceName := c.Param("namespaceName") + crdName := c.Param("crdName") + crName := c.Param("crName") + + resDetail, err := ctrl.service.GetCustomResourceDetail(uint(clusterId), namespaceName, crdName, crName) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, resDetail) +} + +// CreateCustomResource 创建自定义资源 +// @Summary 创建自定义资源 +// @Description 基于前端传递的数据创建自定义资源实例 +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Param namespaceName path string true "命名空间名称" +// @Param crdName path string true "CRD名称" +// @Param data body map[string]interface{} true "资源的 JSON 对象数据" +// @Router /k8s/cluster/{id}/namespaces/{namespaceName}/crds/{crdName}/resources [post] +func (ctrl *K8sCRDController) CreateCustomResource(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + namespaceName := c.Param("namespaceName") + crdName := c.Param("crdName") + + var data map[string]interface{} + if err := c.ShouldBindJSON(&data); err != nil { + result.Failed(c, http.StatusBadRequest, "请求数据格式有误") + return + } + + created, err := ctrl.service.CreateCustomResource(uint(clusterId), namespaceName, crdName, data) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, created) +} + +// DeleteCustomResource 删除自定义资源 +// @Summary 删除自定义资源 +// @Description 删除指定的自定义资源实例 +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Param namespaceName path string true "命名空间名称" +// @Param crdName path string true "CRD名称" +// @Param crName path string true "CR名称" +// @Router /k8s/cluster/{id}/namespaces/{namespaceName}/crds/{crdName}/resources/{crName} [delete] +func (ctrl *K8sCRDController) DeleteCustomResource(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + namespaceName := c.Param("namespaceName") + crdName := c.Param("crdName") + crName := c.Param("crName") + + err = ctrl.service.DeleteCustomResource(uint(clusterId), namespaceName, crdName, crName) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, "自定义资源删除成功") +} + +// GetCustomResourceYaml 获取自定义资源 YAML +// @Summary 获取自定义资源 YAML +// @Description 获取指定的自定义资源实例的 YAML +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Param namespaceName path string true "命名空间名称" +// @Param crdName path string true "CRD名称" +// @Param crName path string true "CR名称" +// @Router /k8s/cluster/{id}/namespaces/{namespaceName}/crds/{crdName}/resources/{crName}/yaml [get] +func (ctrl *K8sCRDController) GetCustomResourceYaml(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + namespaceName := c.Param("namespaceName") + crdName := c.Param("crdName") + crName := c.Param("crName") + + yamlStr, err := ctrl.service.GetCustomResourceYaml(uint(clusterId), namespaceName, crdName, crName) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, yamlStr) +} + +// UpdateCustomResourceYaml 更新自定义资源 YAML +// @Summary 更新自定义资源 YAML +// @Description 使用提供的 YAML 字符串更新指定的自定义资源实例 +// @Tags K8s CRD管理 +// @Accept json +// @Produce json +// @Param id path int true "集群ID" +// @Param namespaceName path string true "命名空间名称" +// @Param crdName path string true "CRD名称" +// @Param crName path string true "CR名称" +// @Param body body map[string]string true "包含 yaml 数据的对象" +// @Router /k8s/cluster/{id}/namespaces/{namespaceName}/crds/{crdName}/resources/{crName}/yaml [put] +func (ctrl *K8sCRDController) UpdateCustomResourceYaml(c *gin.Context) { + clusterIdStr := c.Param("id") + clusterId, err := strconv.Atoi(clusterIdStr) + if err != nil { + result.Failed(c, http.StatusBadRequest, "无效的集群ID") + return + } + + namespaceName := c.Param("namespaceName") + crdName := c.Param("crdName") + crName := c.Param("crName") + + var req struct { + Yaml string `json:"yaml"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + result.Failed(c, http.StatusBadRequest, "请求参数解析失败") + return + } + + updated, err := ctrl.service.UpdateCustomResourceYaml(uint(clusterId), namespaceName, crdName, crName, req.Yaml) + if err != nil { + result.Failed(c, http.StatusInternalServerError, err.Error()) + return + } + + result.Success(c, updated) +} \ No newline at end of file diff --git a/api/api/k8s/dao/k8scrd.go b/api/api/k8s/dao/k8scrd.go new file mode 100644 index 00000000..39ac8bd9 --- /dev/null +++ b/api/api/k8s/dao/k8scrd.go @@ -0,0 +1,65 @@ +package dao + +import ( + "fmt" + + "gorm.io/gorm" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +type IK8sCRDDao interface { + GetDynamicClient(clusterId uint) (dynamic.Interface, error) + GetClientSet(clusterId uint) (*kubernetes.Clientset, error) +} + +type k8sCRDDao struct { + db *gorm.DB +} + +func NewK8sCRDDao(db *gorm.DB) IK8sCRDDao { + return &k8sCRDDao{db: db} +} + +// 获取集群配置并返回 dynamic client +func (d *k8sCRDDao) GetDynamicClient(clusterId uint) (dynamic.Interface, error) { + clusterDao := NewKubeClusterDao(d.db) + cluster, err := clusterDao.GetByID(clusterId) + if err != nil { + return nil, fmt.Errorf("获取集群信息失败: %v", err) + } + + config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.Credential)) + if err != nil { + return nil, fmt.Errorf("解析 kubeconfig 失败: %v", err) + } + + dynClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("创建 dynamic client 失败: %v", err) + } + + return dynClient, nil +} + +// 获取集群配置并返回 clientset (用于获取系统 CRD 列表) +func (d *k8sCRDDao) GetClientSet(clusterId uint) (*kubernetes.Clientset, error) { + clusterDao := NewKubeClusterDao(d.db) + cluster, err := clusterDao.GetByID(clusterId) + if err != nil { + return nil, fmt.Errorf("获取集群信息失败: %v", err) + } + + config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.Credential)) + if err != nil { + return nil, fmt.Errorf("解析 kubeconfig 失败: %v", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("创建 clientset 失败: %v", err) + } + + return clientset, nil +} \ No newline at end of file diff --git a/api/api/k8s/service/k8scrd.go b/api/api/k8s/service/k8scrd.go new file mode 100644 index 00000000..32f18770 --- /dev/null +++ b/api/api/k8s/service/k8scrd.go @@ -0,0 +1,387 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + + "dodevops-api/api/k8s/dao" + + "gorm.io/gorm" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" +) + +type IK8sCRDService interface { + GetCRDGroups(clusterId uint) ([]string, error) + GetCRDList(clusterId uint, params map[string]string) ([]map[string]interface{}, error) + GetCustomResourceList(clusterId uint, namespaceName, crdName string, params map[string]string) (map[string]interface{}, error) + GetCustomResourceDetail(clusterId uint, namespaceName, crdName, crName string) (*unstructured.Unstructured, error) + CreateCustomResource(clusterId uint, namespaceName, crdName string, data map[string]interface{}) (*unstructured.Unstructured, error) + DeleteCustomResource(clusterId uint, namespaceName, crdName, crName string) error + GetCustomResourceYaml(clusterId uint, namespaceName, crdName, crName string) (string, error) + UpdateCustomResourceYaml(clusterId uint, namespaceName, crdName, crName, yamlContent string) (*unstructured.Unstructured, error) +} + +type k8sCRDService struct { + dao dao.IK8sCRDDao +} + +func NewK8sCRDService(db *gorm.DB) IK8sCRDService { + return &k8sCRDService{ + dao: dao.NewK8sCRDDao(db), + } +} + +// 解析 CRD 名字以获取 Group, Resource +// crdName 通常形如 "prometheusrules.monitoring.coreos.com" +func parseCRDName(crdName string) (group, resource string) { + parts := strings.SplitN(crdName, ".", 2) + if len(parts) >= 2 { + return parts[1], parts[0] + } + return "", parts[0] +} + +// 模拟获取 GroupResourceVersion(假设 v1 可用,不准确但通常需要通过 API 发现,这里简化处理。最好的方式是用 Discovery 获取准确版本) +func (s *k8sCRDService) getGVR(clusterId uint, crdName string) (schema.GroupVersionResource, error) { + group, resource := parseCRDName(crdName) + // 动态获取推荐的 version + clientset, err := s.dao.GetClientSet(clusterId) + if err != nil { + return schema.GroupVersionResource{}, err + } + + // 这里通过 Discovery 获取推荐版本比较复杂,简化:我们在知道大部分情况下 v1 或 v1beta1 是首选。 + // 这里简单默认使用 API group 发现的第一版,或者硬编码。真实情况可以使用 discoveryClient 获取。 + version := "v1" // 默认退化 + groups, err := clientset.Discovery().ServerGroups() + if err == nil { + for _, g := range groups.Groups { + if g.Name == group { + version = g.PreferredVersion.Version + break + } + } + } + + return schema.GroupVersionResource{Group: group, Version: version, Resource: resource}, nil +} + +func (s *k8sCRDService) GetCRDGroups(clusterId uint) ([]string, error) { + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return nil, err + } + + gvr := schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} + list, err := dynClient.Resource(gvr).List(context.Background(), metav1.ListOptions{}) + if err != nil { + // 尝试 v1beta1 + gvr.Version = "v1beta1" + list, err = dynClient.Resource(gvr).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("列出 CRD 失败: %v", err) + } + } + + groupMap := make(map[string]bool) + for _, item := range list.Items { + spec, _, _ := unstructured.NestedMap(item.Object, "spec") + group, _, _ := unstructured.NestedString(spec, "group") + if group != "" { + groupMap[group] = true + } + } + + var groups []string + for group := range groupMap { + groups = append(groups, group) + } + return groups, nil +} + +func (s *k8sCRDService) GetCRDList(clusterId uint, params map[string]string) ([]map[string]interface{}, error) { + // k8s 内置的 CRD 通常属于 apiextensions.k8s.io 组 + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return nil, err + } + + gvr := schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} + list, err := dynClient.Resource(gvr).List(context.Background(), metav1.ListOptions{}) + if err != nil { + // 尝试 v1beta1 + gvr.Version = "v1beta1" + list, err = dynClient.Resource(gvr).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("列出 CRD 失败: %v", err) + } + } + + var results []map[string]interface{} + targetGroup := params["group"] + + for _, item := range list.Items { + spec, _, _ := unstructured.NestedMap(item.Object, "spec") + group, _, _ := unstructured.NestedString(spec, "group") + + if targetGroup != "" && group != targetGroup { + continue + } + + names, _, _ := unstructured.NestedMap(spec, "names") + kind, _, _ := unstructured.NestedString(names, "kind") + plural, _, _ := unstructured.NestedString(names, "plural") + scope, _, _ := unstructured.NestedString(spec, "scope") + + results = append(results, map[string]interface{}{ + "name": item.GetName(), + "group": group, + "kind": kind, + "plural": plural, + "scope": scope, + "creationTimestamp": item.GetCreationTimestamp().Time, + }) + } + return results, nil +} + +func (s *k8sCRDService) GetCustomResourceList(clusterId uint, namespaceName, crdName string, params map[string]string) (map[string]interface{}, error) { + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return nil, err + } + + gvr, err := s.getGVR(clusterId, crdName) + if err != nil { + return nil, err + } + + listOptions := metav1.ListOptions{} + if labelsStr, ok := params["labels"]; ok && labelsStr != "" { + listOptions.LabelSelector = labelsStr + } + + var list *unstructured.UnstructuredList + if namespaceName != "" && namespaceName != "all" { + list, err = dynClient.Resource(gvr).Namespace(namespaceName).List(context.Background(), listOptions) + } else { + list, err = dynClient.Resource(gvr).List(context.Background(), listOptions) + } + + if err != nil { + return nil, fmt.Errorf("列出自定义资源失败: %v", err) + } + + var results []map[string]interface{} + for _, item := range list.Items { + name := item.GetName() + kind := item.GetKind() + + // Filter + match := true + if exactName, ok := params["name"]; ok && exactName != "" { + if name != exactName { + match = false + } + } + if keyword, ok := params["keyword"]; ok && keyword != "" { + if !strings.Contains(name, keyword) { + match = false + } + } + if exactKind, ok := params["kind"]; ok && exactKind != "" { + // CRD kind is uniform usually, but just in case + if kind != exactKind { + match = false + } + } + + if match { + results = append(results, item.Object) + } + } + + if results == nil { + results = []map[string]interface{}{} + } + + total := len(results) + + pageStr := params["page"] + pageSizeStr := params["pageSize"] + if pageStr != "" && pageSizeStr != "" { + page, _ := strconv.Atoi(pageStr) + pageSize, _ := strconv.Atoi(pageSizeStr) + if page > 0 && pageSize > 0 { + start := (page - 1) * pageSize + if start > total { + results = []map[string]interface{}{} + } else { + end := start + pageSize + if end > total { + end = total + } + results = results[start:end] + } + } + } + + return map[string]interface{}{ + "items": results, + "total": total, + }, nil +} + +func (s *k8sCRDService) GetCustomResourceDetail(clusterId uint, namespaceName, crdName, crName string) (*unstructured.Unstructured, error) { + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return nil, err + } + + gvr, err := s.getGVR(clusterId, crdName) + if err != nil { + return nil, err + } + + var item *unstructured.Unstructured + if namespaceName != "" && namespaceName != "all" { + item, err = dynClient.Resource(gvr).Namespace(namespaceName).Get(context.Background(), crName, metav1.GetOptions{}) + } else { + item, err = dynClient.Resource(gvr).Get(context.Background(), crName, metav1.GetOptions{}) + } + + if err != nil { + return nil, fmt.Errorf("获取自定义资源详情失败: %v", err) + } + // 去除 managedFields 减小体积 + item.SetManagedFields(nil) + return item, nil +} + +func (s *k8sCRDService) CreateCustomResource(clusterId uint, namespaceName, crdName string, data map[string]interface{}) (*unstructured.Unstructured, error) { + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return nil, err + } + + gvr, err := s.getGVR(clusterId, crdName) + if err != nil { + return nil, err + } + + var finalData map[string]interface{} + if yamlContent, ok := data["yamlContent"].(string); ok && yamlContent != "" { + err = yaml.Unmarshal([]byte(yamlContent), &finalData) + if err != nil { + return nil, fmt.Errorf("解析 YAML 失败: %v", err) + } + } else { + finalData = data + } + + obj := &unstructured.Unstructured{Object: finalData} + + var created *unstructured.Unstructured + if namespaceName != "" && namespaceName != "all" { + created, err = dynClient.Resource(gvr).Namespace(namespaceName).Create(context.Background(), obj, metav1.CreateOptions{}) + } else { + created, err = dynClient.Resource(gvr).Create(context.Background(), obj, metav1.CreateOptions{}) + } + + if err != nil { + return nil, fmt.Errorf("创建自定义资源失败: %v", err) + } + return created, nil +} + +func (s *k8sCRDService) DeleteCustomResource(clusterId uint, namespaceName, crdName, crName string) error { + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return err + } + + gvr, err := s.getGVR(clusterId, crdName) + if err != nil { + return err + } + + if namespaceName != "" && namespaceName != "all" { + err = dynClient.Resource(gvr).Namespace(namespaceName).Delete(context.Background(), crName, metav1.DeleteOptions{}) + } else { + err = dynClient.Resource(gvr).Delete(context.Background(), crName, metav1.DeleteOptions{}) + } + + if err != nil { + return fmt.Errorf("删除自定义资源失败: %v", err) + } + return nil +} + +func (s *k8sCRDService) GetCustomResourceYaml(clusterId uint, namespaceName, crdName, crName string) (string, error) { + item, err := s.GetCustomResourceDetail(clusterId, namespaceName, crdName, crName) + if err != nil { + return "", err + } + + // 移除一些通常不必要展示的状态和元数据 + unstructured.RemoveNestedField(item.Object, "metadata", "managedFields") + // 可以选择是否移除 status + // unstructured.RemoveNestedField(item.Object, "status") + + yamlBytes, err := yaml.Marshal(item.Object) + if err != nil { + return "", fmt.Errorf("格式化 YAML 失败: %v", err) + } + return string(yamlBytes), nil +} + +func (s *k8sCRDService) UpdateCustomResourceYaml(clusterId uint, namespaceName, crdName, crName, yamlContent string) (*unstructured.Unstructured, error) { + dynClient, err := s.dao.GetDynamicClient(clusterId) + if err != nil { + return nil, err + } + + gvr, err := s.getGVR(clusterId, crdName) + if err != nil { + return nil, err + } + + // 先将其转换为 JSON map + var obj map[string]interface{} + err = yaml.Unmarshal([]byte(yamlContent), &obj) + if err != nil { + return nil, fmt.Errorf("YAML 解析为 JSON 错误: %v", err) + } + + unstructObj := &unstructured.Unstructured{Object: obj} + + // 获取现有版本,为了获取 resourceVersion 这是 Update 必须的 + var existing *unstructured.Unstructured + if namespaceName != "" && namespaceName != "all" { + existing, err = dynClient.Resource(gvr).Namespace(namespaceName).Get(context.Background(), crName, metav1.GetOptions{}) + } else { + existing, err = dynClient.Resource(gvr).Get(context.Background(), crName, metav1.GetOptions{}) + } + if err != nil { + return nil, fmt.Errorf("获取现有自定义资源失败: %v", err) + } + + unstructObj.SetResourceVersion(existing.GetResourceVersion()) + + var updated *unstructured.Unstructured + if namespaceName != "" && namespaceName != "all" { + updated, err = dynClient.Resource(gvr).Namespace(namespaceName).Update(context.Background(), unstructObj, metav1.UpdateOptions{}) + } else { + updated, err = dynClient.Resource(gvr).Update(context.Background(), unstructObj, metav1.UpdateOptions{}) + } + + if err != nil { + return nil, fmt.Errorf("更新自定义资源失败: %v", err) + } + return updated, nil +} \ No newline at end of file diff --git a/api/api/task/controller/configansible.go b/api/api/task/controller/configansible.go new file mode 100644 index 00000000..7b9e975a --- /dev/null +++ b/api/api/task/controller/configansible.go @@ -0,0 +1,124 @@ +package controller + +import ( + "dodevops-api/api/task/service" + "dodevops-api/common/result" + "strconv" + + "github.com/gin-gonic/gin" +) + +type ConfigAnsibleController struct { + service service.IConfigAnsibleService +} + +func NewConfigAnsibleController(service service.IConfigAnsibleService) *ConfigAnsibleController { + return &ConfigAnsibleController{service: service} +} + +// Create 创建配置 +// @Summary 创建Ansible配置 +// @Description 创建Inventory/Vars/Args等配置 +// @Tags 配置管理 +// @Accept json +// @Produce json +// @Param request body service.CreateConfigRequest true "创建配置请求" +// @Success 200 {object} result.Result{data=model.ConfigAnsible} +// @Router /api/v1/config/ansible [post] +// @Security ApiKeyAuth +func (c *ConfigAnsibleController) Create(ctx *gin.Context) { + var req service.CreateConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + result.Failed(ctx, 400, "参数错误: "+err.Error()) + return + } + c.service.Create(ctx, &req) +} + +// Update 更新配置 +// @Summary 更新Ansible配置 +// @Description 更新配置内容 +// @Tags 配置管理 +// @Accept json +// @Produce json +// @Param id path int true "配置ID" +// @Param request body service.UpdateConfigRequest true "更新配置请求" +// @Success 200 {object} result.Result{data=model.ConfigAnsible} +// @Router /api/v1/config/ansible/{id} [put] +// @Security ApiKeyAuth +func (c *ConfigAnsibleController) Update(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的ID") + return + } + var req service.UpdateConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + result.Failed(ctx, 400, "参数错误: "+err.Error()) + return + } + c.service.Update(ctx, uint(id), &req) +} + +// Delete 删除配置 +// @Summary 删除Ansible配置 +// @Description 删除指定的配置 +// @Tags 配置管理 +// @Accept json +// @Produce json +// @Param id path int true "配置ID" +// @Success 200 {object} result.Result +// @Router /api/v1/config/ansible/{id} [delete] +// @Security ApiKeyAuth +func (c *ConfigAnsibleController) Delete(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的ID") + return + } + c.service.Delete(ctx, uint(id)) +} + +// Get 获取配置详情 +// @Summary 获取Ansible配置详情 +// @Description 根据ID获取配置详情 +// @Tags 配置管理 +// @Accept json +// @Produce json +// @Param id path int true "配置ID" +// @Success 200 {object} result.Result{data=model.ConfigAnsible} +// @Router /api/v1/config/ansible/{id} [get] +// @Security ApiKeyAuth +func (c *ConfigAnsibleController) Get(ctx *gin.Context) { + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的ID") + return + } + c.service.Get(ctx, uint(id)) +} + +// List 获取配置列表 +// @Summary 获取Ansible配置列表 +// @Description 分页获取配置列表,支持按名称和类型过滤 +// @Tags 配置管理 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param size query int false "每页数量" default(10) +// @Param name query string false "配置名称(模糊查询)" +// @Param type query int false "配置类型(1-inventory 2-global_vars 3-extra_vars 4-cli_args)" +// @Success 200 {object} result.Result{data=dao.ListResponse} +// @Router /api/v1/config/ansible [get] +// @Security ApiKeyAuth +func (c *ConfigAnsibleController) List(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("size", "10")) + name := ctx.Query("name") + configType, _ := strconv.Atoi(ctx.Query("type")) + + c.service.List(ctx, page, size, name, configType) +} \ No newline at end of file diff --git a/api/api/task/controller/taskansible.go b/api/api/task/controller/taskansible.go index 6352d0a6..ff7a5f85 100644 --- a/api/api/task/controller/taskansible.go +++ b/api/api/task/controller/taskansible.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "fmt" "io" "mime/multipart" "net/http" @@ -28,63 +29,6 @@ func NewTaskAnsibleController(service service.ITaskAnsibleService) *TaskAnsibleC return &TaskAnsibleController{service: service} } -// GetJobLog 获取任务日志(SSE实现) -// @Summary 获取Ansible任务日志(SSE) -// @Description 通过SSE协议实时获取Ansible任务执行日志 -// @Tags 任务作业 -// @Accept json -// @Produce text/event-stream -// @Param id path int true "任务ID" -// @Param work_id path int true "子任务ID" -// @Success 200 {object} string "SSE格式的实时日志" -// @Router /api/v1/task/ansible/{id}/log/{work_id} [get] -// @Security ApiKeyAuth -func (c *TaskAnsibleController) GetJobLog(ctx *gin.Context) { - // 先设置SSE响应头,确保正确的Content-Type - ctx.Header("Content-Type", "text/event-stream") - ctx.Header("Cache-Control", "no-cache") - ctx.Header("Connection", "keep-alive") - ctx.Header("Access-Control-Allow-Origin", "*") - - taskID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) - if err != nil { - // SSE格式的错误响应 - ctx.Writer.WriteString("event: error\n") - ctx.Writer.WriteString("data: 无效的任务ID\n\n") - ctx.Writer.Flush() - return - } - - workID, err := strconv.ParseUint(ctx.Param("work_id"), 10, 64) - if err != nil { - // SSE格式的错误响应 - ctx.Writer.WriteString("event: error\n") - ctx.Writer.WriteString("data: 无效的子任务ID\n\n") - ctx.Writer.Flush() - return - } - - c.service.GetJobLog(ctx, uint(taskID), uint(workID)) -} - -// List 获取任务列表 -// @Summary 获取Ansible任务列表 -// @Description 获取Ansible任务列表 -// @Tags 任务作业 -// @Accept json -// @Produce json -// @Param page query int false "页码" default(1) -// @Param size query int false "每页数量" default(10) -// @Success 200 {object} result.Result{data=ListResponse} -// @Router /api/v1/task/ansiblelist [get] -// @Security ApiKeyAuth -func (c *TaskAnsibleController) List(ctx *gin.Context) { - page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(ctx.DefaultQuery("size", "10")) - - c.service.List(ctx, page, size) -} - // CreateTask 创建Ansible任务 // @Summary 创建Ansible任务 // @Description 创建Ansible任务(1=手动,2=Git导入)。K8s部署任务请使用专门的K8s创建接口 @@ -98,6 +42,17 @@ func (c *TaskAnsibleController) List(ctx *gin.Context) { // @Param variables formData string false "全局变量JSON" // @Param playbooks formData file false "playbook文件(type=1时上传)" // @Param roles formData file false "roles目录(type=1时上传)" +// @Param extra_vars formData string false "额外变量(JSON/YAML字符串)" +// @Param cli_args formData string false "命令行参数" +// @Param use_config formData int false "是否使用配置中心(0=否,1=是)" +// @Param inventory_config_id formData int false "Inventory配置ID" +// @Param global_vars_config_id formData int false "全局变量配置ID" +// @Param extra_vars_config_id formData int false "额外变量配置ID" +// @Param cli_args_config_id formData int false "命令行参数配置ID" +// @Param cron_expr formData string false "Cron表达式(周期任务必填)" +// @Param is_recurring formData int false "是否为周期任务(0=否, 1=是)" +// @Param playbook_paths formData string false "Playbook文件路径列表(JSON数组字符串, type=2时可选)" +// @Param view_id formData int false "视图ID" // @Success 200 {object} result.Result{data=model.TaskAnsible} // @Router /api/v1/task/ansible [post] // @Security ApiKeyAuth @@ -151,6 +106,52 @@ func (c *TaskAnsibleController) CreateTask(ctx *gin.Context) { } } + // 解析Playbook Paths (type=2) + playbookPathsJSON := ctx.PostForm("playbook_paths") + var playbookPaths []string + if playbookPathsJSON != "" { + if err := json.Unmarshal([]byte(playbookPathsJSON), &playbookPaths); err != nil { + result.Failed(ctx, http.StatusBadRequest, "playbook_paths参数格式错误") + return + } + } + + // 获取其他新增参数 + extraVars := ctx.PostForm("extra_vars") + cliArgs := ctx.PostForm("cli_args") + useConfig, _ := strconv.Atoi(ctx.PostForm("use_config")) + cronExpr := ctx.PostForm("cron_expr") + isRecurring, _ := strconv.Atoi(ctx.PostForm("is_recurring")) + + // 解析ID字段 + var inventoryConfigID, globalVarsConfigID, extraVarsConfigID, cliArgsConfigID, viewID *uint + + if val := ctx.PostForm("inventory_config_id"); val != "" { + id, _ := strconv.ParseUint(val, 10, 64) + uid := uint(id) + inventoryConfigID = &uid + } + if val := ctx.PostForm("global_vars_config_id"); val != "" { + id, _ := strconv.ParseUint(val, 10, 64) + uid := uint(id) + globalVarsConfigID = &uid + } + if val := ctx.PostForm("extra_vars_config_id"); val != "" { + id, _ := strconv.ParseUint(val, 10, 64) + uid := uint(id) + extraVarsConfigID = &uid + } + if val := ctx.PostForm("cli_args_config_id"); val != "" { + id, _ := strconv.ParseUint(val, 10, 64) + uid := uint(id) + cliArgsConfigID = &uid + } + if val := ctx.PostForm("view_id"); val != "" { + id, _ := strconv.ParseUint(val, 10, 64) + uid := uint(id) + viewID = &uid + } + // 处理文件上传 var rolesFile *multipart.FileHeader var playbookFiles []*multipart.FileHeader @@ -197,46 +198,75 @@ func (c *TaskAnsibleController) CreateTask(ctx *gin.Context) { // 构建请求参数 req := &service.CreateTaskRequest{ - TaskType: taskType, - Name: name, - HostGroups: hostGroups, - GitRepo: gitRepo, - RolesContent: rolesContent, - PlaybookContents: playbookContents, - Variables: variables, + TaskType: taskType, + Name: name, + HostGroups: hostGroups, + GitRepo: gitRepo, + RolesContent: rolesContent, + PlaybookContents: playbookContents, + Variables: variables, + PlaybookPaths: playbookPaths, + ExtraVars: extraVars, + CliArgs: cliArgs, + UseConfig: useConfig, + InventoryConfigID: inventoryConfigID, + GlobalVarsConfigID: globalVarsConfigID, + ExtraVarsConfigID: extraVarsConfigID, + CliArgsConfigID: cliArgsConfigID, + CronExpr: cronExpr, + IsRecurring: isRecurring, + ViewID: viewID, } // 调用服务层 c.service.CreateTask(ctx, req) } -// AnsibleTaskRequest 创建Ansible任务请求 -// AnsibleTaskRequest 创建任务请求参数 -type AnsibleTaskRequest struct { - Type int `json:"type" form:"type" binding:"required,oneof=1 2"` // 任务类型(1=手动,2=Git导入) - Name string `json:"name" form:"name" binding:"required"` // 任务名称 - HostGroups map[string][]uint `json:"hostGroups" form:"hostGroups" binding:"required"` // 主机分组 - GitRepo string `json:"gitRepo,omitempty" form:"gitRepo"` // Git仓库地址(类型为2时必填) +// UpdateTask 修改Ansible任务 +// @Summary 修改Ansible任务 +// @Description 修改Ansible任务基本信息和配置(运行中任务不可修改) +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param id path int true "任务ID" +// @Param request body service.UpdateTaskRequest true "修改任务请求" +// @Success 200 {object} result.Result{data=model.TaskAnsible} +// @Router /api/v1/task/ansible/{id} [put] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) UpdateTask(ctx *gin.Context) { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil { + result.Failed(ctx, http.StatusBadRequest, "无效的任务ID") + return + } + + var req service.UpdateTaskRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + result.Failed(ctx, http.StatusBadRequest, fmt.Sprintf("参数错误: %v", err)) + return + } + + c.service.UpdateTask(ctx, uint(id), &req) } -// GetTask 获取任务详情 -// @Summary 获取Ansible任务详情 -// @Description 获取Ansible任务详情 +// DeleteTask 删除Ansible任务 +// @Summary 删除Ansible任务 +// @Description 删除指定的Ansible任务(级联删除关联的子任务) // @Tags 任务作业 // @Accept json // @Produce json // @Param id path int true "任务ID" -// @Success 200 {object} result.Result{data=model.TaskAnsible} -// @Router /api/v1/task/ansible/{id} [get] +// @Success 200 {object} result.Result +// @Router /api/v1/task/ansible/{id} [delete] // @Security ApiKeyAuth -func (c *TaskAnsibleController) GetTask(ctx *gin.Context) { +func (c *TaskAnsibleController) DeleteTask(ctx *gin.Context) { id, err := strconv.Atoi(ctx.Param("id")) if err != nil { result.Failed(ctx, http.StatusBadRequest, "无效的任务ID") return } - c.service.GetTaskDetail(ctx, uint(id)) + c.service.DeleteTask(ctx, uint(id)) } // StartTask 启动Ansible任务 @@ -259,24 +289,81 @@ func (c *TaskAnsibleController) StartTask(ctx *gin.Context) { c.service.StartJob(ctx, uint(id)) } -// DeleteTask 删除Ansible任务 -// @Summary 删除Ansible任务 -// @Description 删除指定的Ansible任务(级联删除关联的子任务) +// GetJobLog 获取任务日志(SSE实现) +// @Summary 获取Ansible任务日志(SSE) +// @Description 通过SSE协议实时获取Ansible任务执行日志 +// @Tags 任务作业 +// @Accept json +// @Produce text/event-stream +// @Param id path int true "任务ID" +// @Param work_id path int true "子任务ID" +// @Success 200 {object} string "SSE格式的实时日志" +// @Router /api/v1/task/ansible/{id}/log/{work_id} [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) GetJobLog(ctx *gin.Context) { + // 先设置SSE响应头,确保正确的Content-Type + ctx.Header("Content-Type", "text/event-stream") + ctx.Header("Cache-Control", "no-cache") + ctx.Header("Connection", "keep-alive") + ctx.Header("Access-Control-Allow-Origin", "*") + + taskID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + // SSE格式的错误响应 + ctx.Writer.WriteString("event: error\n") + ctx.Writer.WriteString("data: 无效的任务ID\n\n") + ctx.Writer.Flush() + return + } + + workID, err := strconv.ParseUint(ctx.Param("work_id"), 10, 64) + if err != nil { + // SSE格式的错误响应 + ctx.Writer.WriteString("event: error\n") + ctx.Writer.WriteString("data: 无效的子任务ID\n\n") + ctx.Writer.Flush() + return + } + + c.service.GetJobLog(ctx, uint(taskID), uint(workID)) +} + +// List 获取任务列表 +// @Summary 获取Ansible任务列表 +// @Description 获取Ansible任务列表 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param size query int false "每页数量" default(10) +// @Success 200 {object} result.Result{data=ListResponse} +// @Router /api/v1/task/ansiblelist [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) List(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("size", "10")) + + c.service.List(ctx, page, size) +} + +// GetTask 获取任务详情 +// @Summary 获取Ansible任务详情 +// @Description 获取Ansible任务详情 // @Tags 任务作业 // @Accept json // @Produce json // @Param id path int true "任务ID" -// @Success 200 {object} result.Result -// @Router /api/v1/task/ansible/{id} [delete] +// @Success 200 {object} result.Result{data=model.TaskAnsible} +// @Router /api/v1/task/ansible/{id} [get] // @Security ApiKeyAuth -func (c *TaskAnsibleController) DeleteTask(ctx *gin.Context) { +func (c *TaskAnsibleController) GetTask(ctx *gin.Context) { id, err := strconv.Atoi(ctx.Param("id")) if err != nil { result.Failed(ctx, http.StatusBadRequest, "无效的任务ID") return } - c.service.DeleteTask(ctx, uint(id)) + c.service.GetTaskDetail(ctx, uint(id)) } // GetTasksByName 根据名称模糊查询任务 @@ -325,6 +412,29 @@ func (c *TaskAnsibleController) GetTasksByType(ctx *gin.Context) { c.service.GetTasksByType(ctx, taskType) } +// GetTasks 查询任务列表 (多条件) +// @Summary 查询任务列表 +// @Description 支持任务名称、类型、视图名称的多条件查询和分页 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param name query string false "任务名称(模糊)" +// @Param type query int false "任务类型" +// @Param viewName query string false "视图名称" +// @Param page query int false "页码" default(1) +// @Param size query int false "每页数量" default(10) +// @Success 200 {object} result.Result{data=ListResponse} +// @Router /api/v1/task/ansible/query [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) GetTasks(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("size", "10")) + name := ctx.Query("name") + taskType, _ := strconv.Atoi(ctx.Query("type")) + viewName := ctx.Query("viewName") + + c.service.GetTasks(ctx, name, taskType, viewName, page, size) +} // CreateK8sTask 创建K8s部署任务 // @Summary 创建K8s部署任务 @@ -390,19 +500,19 @@ func (c *TaskAnsibleController) CreateK8sTask(ctx *gin.Context) { // 解析主机ID数组 var masterHostIDs, workerHostIDs, etcdHostIDs []uint - + if err := json.Unmarshal([]byte(masterHostIDsJSON), &masterHostIDs); err != nil { result.Failed(ctx, http.StatusBadRequest, "Master节点主机ID格式错误") return } - + if workerHostIDsJSON != "" { if err := json.Unmarshal([]byte(workerHostIDsJSON), &workerHostIDs); err != nil { result.Failed(ctx, http.StatusBadRequest, "Worker节点主机ID格式错误") return } } - + if err := json.Unmarshal([]byte(etcdHostIDsJSON), &etcdHostIDs); err != nil { result.Failed(ctx, http.StatusBadRequest, "ETCD节点主机ID格式错误") return @@ -422,20 +532,137 @@ func (c *TaskAnsibleController) CreateK8sTask(ctx *gin.Context) { // 构建K8s任务请求 req := &service.CreateK8sTaskRequest{ - Name: name, - Description: description, - ClusterName: clusterName, - ClusterVersion: clusterVersion, - DeploymentMode: deploymentMode, - MasterHostIDs: masterHostIDs, - WorkerHostIDs: workerHostIDs, - EtcdHostIDs: etcdHostIDs, + Name: name, + Description: description, + ClusterName: clusterName, + ClusterVersion: clusterVersion, + DeploymentMode: deploymentMode, + MasterHostIDs: masterHostIDs, + WorkerHostIDs: workerHostIDs, + EtcdHostIDs: etcdHostIDs, EnabledComponents: enabledComponents, - PrivateRegistry: privateRegistry, - RegistryUsername: registryUsername, - RegistryPassword: registryPassword, + PrivateRegistry: privateRegistry, + RegistryUsername: registryUsername, + RegistryPassword: registryPassword, } // 调用服务层 c.service.CreateK8sTask(ctx, req) } + +// GetTaskHistoryList 获取任务历史记录列表 +// @Summary 获取任务历史记录列表 +// @Description 获取任务的历史执行记录列表,支持分页 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param id path int true "任务ID" +// @Param page query int false "页码" default(1) +// @Param limit query int false "每页数量" default(10) +// @Success 200 {object} result.Result{data=map[string]interface{}} +// @Router /api/v1/task/ansible/{id}/history [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) GetTaskHistoryList(ctx *gin.Context) { + taskID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的任务ID") + return + } + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + c.service.GetTaskHistoryList(ctx, uint(taskID), page, limit) +} + +// GetTaskHistoryDetail 获取任务历史记录详情 +// @Summary 获取任务历史记录详情 +// @Description 获取任务的历史执行详情,包含每个主机的执行日志 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param history_id path int true "历史记录ID" +// @Success 200 {object} result.Result{data=model.TaskAnsibleHistory} +// @Router /api/v1/task/ansible/history/{history_id} [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) GetTaskHistoryDetail(ctx *gin.Context) { + historyID, err := strconv.ParseUint(ctx.Param("history_id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的历史ID") + return + } + + c.service.GetTaskHistoryDetail(ctx, uint(historyID)) +} + +// GetTaskHistoryLog 获取历史记录日志内容 +// @Summary 获取历史记录日志内容 +// @Description 获取指定子任务历史记录的日志内容 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param work_history_id path int true "子任务历史记录ID" +// @Success 200 {object} result.Result{data=string} +// @Router /api/v1/task/ansible/history/work/{work_history_id}/log [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) GetTaskHistoryLog(ctx *gin.Context) { + workHistoryID, err := strconv.ParseUint(ctx.Param("work_history_id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的ID") + return + } + + c.service.GetTaskHistoryLog(ctx, uint(workHistoryID)) +} + +// GetTaskHistoryLogByDetails 获取历史记录日志内容(通过详细信息) +// @Summary 获取历史记录日志内容(通过详细信息) +// @Description 根据任务ID、WORKID和HistoryID获取历史任务日志 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param task_id path int true "任务ID" +// @Param work_id path int true "子任务ID" +// @Param history_id path int true "历史记录ID" +// @Success 200 {object} result.Result{data=string} +// @Router /api/v1/task/ansible/history/detail/task/{task_id}/work/{work_id}/history/{history_id}/log [get] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) GetTaskHistoryLogByDetails(ctx *gin.Context) { + taskID, err := strconv.ParseUint(ctx.Param("task_id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的任务ID") + return + } + workID, err := strconv.ParseUint(ctx.Param("work_id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的子任务ID") + return + } + historyID, err := strconv.ParseUint(ctx.Param("history_id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的历史记录ID") + return + } + + c.service.GetTaskHistoryLogByDetails(ctx, uint(taskID), uint(workID), uint(historyID)) +} + +// DeleteTaskHistory 删除任务历史记录 +// @Summary 删除任务历史记录 +// @Description 删除指定的任务历史记录及关联的日志文件 +// @Tags 任务作业 +// @Accept json +// @Produce json +// @Param id path int true "任务ID" +// @Param history_id path int true "历史记录ID" +// @Success 200 {object} result.Result +// @Router /api/v1/task/ansible/{id}/history/{history_id} [delete] +// @Security ApiKeyAuth +func (c *TaskAnsibleController) DeleteTaskHistory(ctx *gin.Context) { + historyID, err := strconv.ParseUint(ctx.Param("history_id"), 10, 64) + if err != nil { + result.Failed(ctx, 400, "无效的历史记录ID") + return + } + + c.service.DeleteTaskHistory(ctx, uint(historyID)) +} \ No newline at end of file diff --git a/api/api/task/dao/configansible.go b/api/api/task/dao/configansible.go new file mode 100644 index 00000000..2306563b --- /dev/null +++ b/api/api/task/dao/configansible.go @@ -0,0 +1,69 @@ +package dao + +import ( + "dodevops-api/api/task/model" + + "gorm.io/gorm" +) + +type ConfigAnsibleDao struct { + DB *gorm.DB +} + +func NewConfigAnsibleDao(db *gorm.DB) *ConfigAnsibleDao { + return &ConfigAnsibleDao{DB: db} +} + +// Create 创建配置 +func (d *ConfigAnsibleDao) Create(config *model.ConfigAnsible) error { + return d.DB.Create(config).Error +} + +// Update 更新配置 +func (d *ConfigAnsibleDao) Update(config *model.ConfigAnsible) error { + return d.DB.Save(config).Error +} + +// Delete 删除配置 +func (d *ConfigAnsibleDao) Delete(id uint) error { + return d.DB.Delete(&model.ConfigAnsible{}, id).Error +} + +// GetByID 根据ID获取配置 +func (d *ConfigAnsibleDao) GetByID(id uint) (*model.ConfigAnsible, error) { + var config model.ConfigAnsible + err := d.DB.First(&config, id).Error + return &config, err +} + +// ListResponse 列表响应结构 +type ListResponse struct { + List []model.ConfigAnsible `json:"list"` + Total int64 `json:"total"` +} + +// List 获取配置列表(支持多条件查询) +func (d *ConfigAnsibleDao) List(page, size int, name string, configType int) (*ListResponse, error) { + var configs []model.ConfigAnsible + var total int64 + db := d.DB.Model(&model.ConfigAnsible{}) + + if name != "" { + db = db.Where("name LIKE ?", "%"+name+"%") + } + if configType > 0 { + db = db.Where("type = ?", configType) + } + + err := db.Count(&total). + Offset((page - 1) * size). + Limit(size). + Order("id desc"). + Find(&configs).Error + + if err != nil { + return nil, err + } + + return &ListResponse{List: configs, Total: total}, nil +} \ No newline at end of file diff --git a/api/api/task/dao/taskansible.go b/api/api/task/dao/taskansible.go index e615cc84..1282ecab 100644 --- a/api/api/task/dao/taskansible.go +++ b/api/api/task/dao/taskansible.go @@ -1,8 +1,11 @@ package dao import ( - "fmt" "dodevops-api/api/task/model" + "fmt" + "os" + "path/filepath" + "strings" "sync" "time" @@ -34,24 +37,24 @@ const cacheTTL = 5 * time.Second // 5秒缓存TTL func (d *TaskAnsibleDao) getFromCache(key string) (interface{}, bool) { d.mutex.RLock() defer d.mutex.RUnlock() - + item, exists := d.cache[key] if !exists { return nil, false } - + // 检查是否过期 if time.Since(item.timestamp) > cacheTTL { return nil, false } - + return item.data, true } func (d *TaskAnsibleDao) setCache(key string, data interface{}) { d.mutex.Lock() defer d.mutex.Unlock() - + d.cache[key] = &cacheItem{ data: data, timestamp: time.Now(), @@ -61,7 +64,7 @@ func (d *TaskAnsibleDao) setCache(key string, data interface{}) { func (d *TaskAnsibleDao) clearCache(pattern string) { d.mutex.Lock() defer d.mutex.Unlock() - + // 简单实现:清空所有缓存 d.cache = make(map[string]*cacheItem) } @@ -149,7 +152,7 @@ func (d *TaskAnsibleDao) GetWorkByID(taskID, workID uint) (*model.TaskAnsibleWor return work, nil } } - + var work model.TaskAnsibleWork // 只查询必要的字段,减少数据传输 err := d.DB.Select("id, task_id, entry_file_name, log_path, status, start_time, end_time"). @@ -157,7 +160,7 @@ func (d *TaskAnsibleDao) GetWorkByID(taskID, workID uint) (*model.TaskAnsibleWor if err != nil { return nil, err } - + // 存入缓存 d.setCache(cacheKey, &work) return &work, nil @@ -195,18 +198,18 @@ func (d *TaskAnsibleDao) GetTaskDetail(taskID uint) (*model.TaskAnsible, error) return task, nil } } - + var task model.TaskAnsible // 只预加载Works的关键字段,减少数据传输 err := d.DB.Preload("Works", func(db *gorm.DB) *gorm.DB { return db.Select("id, task_id, entry_file_name, status, start_time, end_time, duration") }).Where("id = ?", taskID).First(&task).Error - + if err == nil { // 存入缓存 d.setCache(cacheKey, &task) } - + return &task, err } @@ -219,18 +222,18 @@ func (d *TaskAnsibleDao) GetWorkStatus(taskID, workID uint) (int, error) { return status, nil } } - + var status int err := d.DB.Model(&model.TaskAnsibleWork{}). Select("status"). Where("task_id = ? AND id = ?", taskID, workID). Scan(&status).Error - + if err == nil { // 存入缓存 d.setCache(cacheKey, status) } - + return status, err } @@ -239,12 +242,12 @@ func (d *TaskAnsibleDao) StartJob(taskID uint) error { err := d.DB.Model(&model.TaskAnsible{}). Where("id = ?", taskID). Update("status", 2).Error // 2表示运行中 - + if err == nil { // 清空相关缓存 d.clearCache("") } - + return err } @@ -257,3 +260,156 @@ func (d *TaskAnsibleDao) StopJob(taskID, workID uint) error { "end_time": gorm.Expr("NOW()"), }).Error } + +// CreateTaskAnsibleHistory 创建任务历史记录 +func (d *TaskAnsibleDao) CreateTaskAnsibleHistory(history *model.TaskAnsibleHistory) error { + return d.DB.Create(history).Error +} + +// CreateTaskAnsibleworkHistories 批量创建子任务历史详情 +func (d *TaskAnsibleDao) CreateTaskAnsibleworkHistories(items []model.TaskAnsibleworkHistory) error { + return d.DB.Create(&items).Error +} + +// UpdateTaskAnsibleHistory 更新任务历史记录 +func (d *TaskAnsibleDao) UpdateTaskAnsibleHistory(history *model.TaskAnsibleHistory) error { + return d.DB.Save(history).Error +} + +// GetTaskAnsibleHistoryList 获取任务历史记录列表 +func (d *TaskAnsibleDao) GetTaskAnsibleHistoryList(taskID uint, page, limit int) ([]model.TaskAnsibleHistory, int64, error) { + var histories []model.TaskAnsibleHistory + var total int64 + + db := d.DB.Model(&model.TaskAnsibleHistory{}).Where("task_id = ?", taskID) + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := db.Order("created_at desc").Offset((page - 1) * limit).Limit(limit).Find(&histories).Error + return histories, total, err +} + +// GetTaskAnsibleHistoryDetail 获取任务历史记录详情(包含WorkLogs) +func (d *TaskAnsibleDao) GetTaskAnsibleHistoryDetail(historyID uint) (*model.TaskAnsibleHistory, error) { + var history model.TaskAnsibleHistory + err := d.DB.Preload("WorkHistories").First(&history, historyID).Error + return &history, err +} + +// DeleteOldHistory 删除旧的历史记录,保留最近 N 条 +func (d *TaskAnsibleDao) DeleteOldHistory(taskID uint, maxKeep int) error { + var count int64 + d.DB.Model(&model.TaskAnsibleHistory{}).Where("task_id = ?", taskID).Count(&count) + + if count > int64(maxKeep) { + // 找出要删除的ID + var historyIDs []uint + // MySQL requires LIMIT when using OFFSET. We use count as a safe upper bound. + err := d.DB.Model(&model.TaskAnsibleHistory{}). + Select("id"). + Where("task_id = ?", taskID). + Order("created_at desc"). + Offset(maxKeep). + Limit(int(count)). + Find(&historyIDs).Error + + if err != nil { + return err + } + + if len(historyIDs) > 0 { + // 删除关联的日志文件目录 + var workHistories []model.TaskAnsibleworkHistory + if err := d.DB.Select("log_path").Where("history_id IN ?", historyIDs).Find(&workHistories).Error; err == nil { + for _, work := range workHistories { + if work.LogPath != "" { + // work.LogPath example: logs/ansible/103/102/20260128205209/deploy.log + // 计算需要删除的目录 (run_id目录) + dirToDelete := filepath.Dir(work.LogPath) + + // 安全检查:确保要删除的目录在 logs/ansible 之下,且长度合理 + if len(dirToDelete) > 12 && strings.HasPrefix(dirToDelete, "logs/ansible") { + // 删除该目录及其内容 + // 使用相对路径,假设程序运行在项目根目录 + os.RemoveAll(dirToDelete) + } + } + } + } + + // 删除子表 + if err := d.DB.Where("history_id IN ?", historyIDs).Delete(&model.TaskAnsibleworkHistory{}).Error; err != nil { + return err + } + + // 删除主表 + if err := d.DB.Where("id IN ?", historyIDs).Delete(&model.TaskAnsibleHistory{}).Error; err != nil { + return err + } + } + } + return nil +} + +// DeleteHistory 删除历史记录 +func (d *TaskAnsibleDao) DeleteHistory(historyID uint) error { + // 开启事务 + tx := d.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 1. 删除子表 TaskAnsibleworkHistory + if err := tx.Where("history_id = ?", historyID).Delete(&model.TaskAnsibleworkHistory{}).Error; err != nil { + tx.Rollback() + return err + } + + // 2. 删除主表 TaskAnsibleHistory + if err := tx.Where("id = ?", historyID).Delete(&model.TaskAnsibleHistory{}).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +// GetTasks 查询任务列表 (多条件) +func (d *TaskAnsibleDao) GetTasks(name string, taskType int, viewName string, page, size int) ([]model.TaskAnsible, int64, error) { + var tasks []model.TaskAnsible + var total int64 + + // 基础查询 + query := d.DB.Model(&model.TaskAnsible{}).Preload("View") + + // 关联查询视图表,以便按视图名称筛选 + if viewName != "" { + query = query.Joins("JOIN task_ansible_view ON task_ansible.view_id = task_ansible_view.id"). + Where("task_ansible_view.name = ?", viewName) + } + + // 任务名称模糊查询 + if name != "" { + query = query.Where("task_ansible.name LIKE ?", "%"+name+"%") + } + + // 任务类型查询 + if taskType != 0 { + query = query.Where("task_ansible.type = ?", taskType) + } + + // 计算总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + if err := query.Order("task_ansible.id DESC").Offset((page - 1) * size).Limit(size).Find(&tasks).Error; err != nil { + return nil, 0, err + } + + return tasks, total, nil +} \ No newline at end of file diff --git a/api/api/task/model/configansible.go b/api/api/task/model/configansible.go new file mode 100644 index 00000000..66373431 --- /dev/null +++ b/api/api/task/model/configansible.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// ConfigAnsible Ansible配置中心 +type ConfigAnsible struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:'主键ID'"` + Name string `gorm:"size:100;not null;uniqueIndex:uk_config_ansible_name;comment:'配置名称'"` + Type int `gorm:"not null;index:idx_config_ansible_type;comment:'1-inventory 2-global_vars 3-extra_vars 4-cli_args'"` + Content string `gorm:"type:longtext;not null;comment:'内容:inventory为文本,vars/args为JSON'"` + Remark string `gorm:"size:500;comment:'备注'"` + CreatedBy string `gorm:"size:50;comment:'创建人'"` + UpdatedBy string `gorm:"size:50;comment:'更新人'"` + CreatedAt time.Time `gorm:"not null;comment:'创建时间'"` + UpdatedAt time.Time `gorm:"not null;comment:'更新时间'"` +} + +func (ConfigAnsible) TableName() string { + return "config_ansible" +} \ No newline at end of file diff --git a/api/api/task/model/taskansible.go b/api/api/task/model/taskansible.go index ac661a1a..b06756d0 100644 --- a/api/api/task/model/taskansible.go +++ b/api/api/task/model/taskansible.go @@ -4,23 +4,39 @@ import "time" // TaskAnsible Ansible任务主表 type TaskAnsible struct { - ID uint `gorm:"primaryKey;comment:'主键ID'"` - Name string `gorm:"size:100;not null;uniqueIndex;comment:'任务名称'"` - Description string `gorm:"type:text;comment:'任务描述'"` - Type int `gorm:"not null;default:1;comment:'任务类型:1-手动,2-Git,3-K8s'"` - GitRepo string `gorm:"size:255;comment:'Git仓库地址'"` - HostGroups string `gorm:"type:text;not null;comment:'主机分组JSON'"` - AllHostIDs string `gorm:"type:text;not null;comment:'所有主机ID JSON数组'"` - GlobalVars string `gorm:"type:text;comment:'全局变量JSON'"` - Status int `json:"status" gorm:"not null;default:1;index:idx_task_status;comment:'任务状态:1-等待中,2-运行中,3-成功,4-异常'"` - ErrorMsg string `gorm:"type:text;comment:'错误信息'"` - TaskCount int `gorm:"not null;default:0;comment:'任务数量(Type=1时为上传文件数,Type=2时为解析的playbook数,Type=3时固定为1)'"` - TotalDuration int `gorm:"not null;default:0;comment:'任务执行总耗时(秒,所有子任务耗时总和)'"` - CreatedAt time.Time `gorm:"not null;comment:'创建时间'"` - UpdatedAt time.Time `gorm:"not null;comment:'更新时间'"` - Works []TaskAnsibleWork `gorm:"foreignKey:TaskID;comment:'子任务列表'"` + ID uint `gorm:"primaryKey;comment:'主键ID'"` + Name string `gorm:"size:100;not null;uniqueIndex;comment:'任务名称'"` + Description string `gorm:"type:text;comment:'任务描述'"` + Type int `gorm:"not null;default:1;comment:'任务类型:1-手动,2-Git,3-K8s'"` + GitRepo string `gorm:"size:255;comment:'Git仓库地址'"` + HostGroups string `gorm:"type:text;not null;comment:'主机分组JSON'"` + AllHostIDs string `gorm:"type:text;not null;comment:'所有主机ID JSON数组'"` + GlobalVars string `gorm:"type:text;comment:'全局变量JSON'"` + ExtraVars string `gorm:"type:text;comment:'额外参数YAML/JSON'"` + CliArgs string `gorm:"type:text;comment:'cli命令行参数'"` + Status int `json:"status" gorm:"not null;default:1;index:idx_task_status;comment:'任务状态:1-等待中,2-运行中,3-成功,4-异常'"` + ErrorMsg string `gorm:"type:text;comment:'错误信息'"` + TaskCount int `gorm:"not null;default:0;comment:'任务数量(Type=1时为上传文件数,Type=2时为解析的playbook数,Type=3时固定为1)'"` + TotalDuration int `gorm:"not null;default:0;comment:'任务执行总耗时(秒,所有子任务耗时总和)'"` + UseConfig int `gorm:"not null;default:0;comment:'是否使用配置管理中的参数 0-不使用,1-使用'"` + InventoryConfigID *uint `gorm:"comment:'选用的inventory配置ID'"` + GlobalVarsConfigID *uint `gorm:"comment:'选用的global_vars配置ID'"` + ExtraVarsConfigID *uint `gorm:"comment:'选用的extra_vars配置ID'"` + CliArgsConfigID *uint `gorm:"comment:'选用的cli_args配置ID'"` + MaxHistoryKeep int `gorm:"default:3;comment:'最大保留历史记录数'"` + CreatedAt time.Time `gorm:"not null;comment:'创建时间'"` + UpdatedAt time.Time `gorm:"not null;comment:'更新时间'"` + Works []TaskAnsibleWork `gorm:"foreignKey:TaskID;comment:'子任务列表'"` + CronExpr string `gorm:"size:64;comment:'定时表达式'"` + IsRecurring int `gorm:"not null;default:0;comment:'是否周期性任务:0-否,1-是'"` + ViewID *uint `gorm:"comment:'视图ID'"` + View *TaskAnsibleView `gorm:"foreignKey:ViewID"` + InventoryConfig *ConfigAnsible `gorm:"foreignKey:InventoryConfigID"` + GlobalVarsConfig *ConfigAnsible `gorm:"foreignKey:GlobalVarsConfigID"` + ExtraVarsConfig *ConfigAnsible `gorm:"foreignKey:ExtraVarsConfigID"` + CliArgsConfig *ConfigAnsible `gorm:"foreignKey:CliArgsConfigID"` } func (TaskAnsible) TableName() string { return "task_ansible" -} +} \ No newline at end of file diff --git a/api/api/task/model/taskansiblehistory.go b/api/api/task/model/taskansiblehistory.go new file mode 100644 index 00000000..2da3402d --- /dev/null +++ b/api/api/task/model/taskansiblehistory.go @@ -0,0 +1,43 @@ +package model + +import "time" + +// TaskAnsibleHistory 任务执行历史记录主表 +type TaskAnsibleHistory struct { + ID uint `gorm:"primaryKey;comment:'主键ID'"` + TaskID uint `gorm:"not null;index:idx_history_task_id;comment:'关联的任务ID'"` + UniqId string `gorm:"size:50;not null;comment:'任务唯一标识(每次执行生成)'"` + Status int `json:"status" gorm:"not null;default:1;comment:'执行状态:1-等待中,2-运行中,3-成功,4-异常'"` + ErrorMsg string `gorm:"type:text;comment:'错误信息'"` + TotalDuration int `gorm:"not null;default:0;comment:'任务执行总耗时(秒)'"` + Trigger int `gorm:"not null;default:1;comment:'触发方式:1-手动,2-定时,3-API'"` + OperatorID uint `gorm:"comment:'操作人ID'"` + OperatorName string `gorm:"size:50;comment:'操作人姓名'"` + StartedAt *time.Time + FinishedAt *time.Time + CreatedAt time.Time + + TaskAnsible *TaskAnsible `gorm:"foreignKey:TaskID"` + WorkHistories []TaskAnsibleworkHistory `gorm:"foreignKey:HistoryID"` +} + +func (TaskAnsibleHistory) TableName() string { + return "task_ansible_history" +} + +// TaskAnsibleworkHistory 任务执行历史记录详情表(对应每个host的执行结果) +type TaskAnsibleworkHistory struct { + ID uint `gorm:"primaryKey;comment:'主键ID'"` + HistoryID uint `gorm:"not null;index:idx_work_history_id;comment:'关联的历史记录ID'"` + TaskID uint `gorm:"not null;comment:'关联的任务ID'"` // 为了方便查询保留 + WorkID uint `gorm:"comment:'关联的WorkID(如果有)'"` + HostName string `gorm:"size:255;not null;comment:'主机名/IP'"` + Status int `gorm:"not null;default:1;comment:'状态:1-等待,2-执行中,3-成功,4-失败,5-跳过'"` + LogPath string `gorm:"size:255;comment:'日志文件路径'"` + Duration int `gorm:"not null;default:0;comment:'耗时(秒)'"` + CreatedAt time.Time +} + +func (TaskAnsibleworkHistory) TableName() string { + return "task_ansiblework_history" +} \ No newline at end of file diff --git a/api/api/task/model/taskansibleview.go b/api/api/task/model/taskansibleview.go new file mode 100644 index 00000000..9973181e --- /dev/null +++ b/api/api/task/model/taskansibleview.go @@ -0,0 +1,15 @@ +package model + +import "time" + +// TaskAnsibleView Ansible任务视图表 +type TaskAnsibleView struct { + ID uint `gorm:"primaryKey;comment:'主键ID'"` + Name string `gorm:"size:100;not null;uniqueIndex;comment:'视图名称'"` + CreatedAt time.Time `gorm:"autoCreateTime;comment:'创建时间'"` + UpdatedAt time.Time `gorm:"autoUpdateTime;comment:'更新时间'"` +} + +func (TaskAnsibleView) TableName() string { + return "task_ansible_view" +} \ No newline at end of file diff --git a/api/api/task/service/configansible.go b/api/api/task/service/configansible.go new file mode 100644 index 00000000..c698ab36 --- /dev/null +++ b/api/api/task/service/configansible.go @@ -0,0 +1,137 @@ +package service + +import ( + "dodevops-api/api/task/dao" + "dodevops-api/api/task/model" + "dodevops-api/common/result" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type IConfigAnsibleService interface { + Create(c *gin.Context, req *CreateConfigRequest) + Update(c *gin.Context, id uint, req *UpdateConfigRequest) + Delete(c *gin.Context, id uint) + Get(c *gin.Context, id uint) + List(c *gin.Context, page, size int, name string, configType int) +} + +type ConfigAnsibleServiceImpl struct { + dao *dao.ConfigAnsibleDao +} + +func NewConfigAnsibleService(db *gorm.DB) IConfigAnsibleService { + return &ConfigAnsibleServiceImpl{ + dao: dao.NewConfigAnsibleDao(db), + } +} + +type CreateConfigRequest struct { + Name string `json:"name" binding:"required"` + Type int `json:"type" binding:"required,oneof=1 2 3 4"` // 1-inventory 2-global_vars 3-extra_vars 4-cli_args + Content string `json:"content" binding:"required"` + Remark string `json:"remark"` +} + +type UpdateConfigRequest struct { + Name string `json:"name"` + Type int `json:"type"` + Content string `json:"content"` + Remark string `json:"remark"` +} + +func (s *ConfigAnsibleServiceImpl) Create(c *gin.Context, req *CreateConfigRequest) { + // 检查名称是否存在 + existing, _ := s.dao.List(1, 1, req.Name, 0) + if existing.Total > 0 { + for _, item := range existing.List { + if item.Name == req.Name { + result.Failed(c, 400, "配置名称已存在") + return + } + } + } + + // 获取当前用户(假设从中间件获取,这里模拟) + username := c.GetString("username") + if username == "" { + username = "system" + } + + config := &model.ConfigAnsible{ + Name: req.Name, + Type: req.Type, + Content: req.Content, + Remark: req.Remark, + CreatedBy: username, + UpdatedBy: username, + } + + if err := s.dao.Create(config); err != nil { + result.Failed(c, 500, "创建配置失败: "+err.Error()) + return + } + result.Success(c, config) +} + +func (s *ConfigAnsibleServiceImpl) Update(c *gin.Context, id uint, req *UpdateConfigRequest) { + config, err := s.dao.GetByID(id) + if err != nil { + result.Failed(c, 404, "配置不存在") + return + } + + if req.Name != "" { + config.Name = req.Name + } + if req.Type > 0 { + config.Type = req.Type + } + if req.Content != "" { + config.Content = req.Content + } + if req.Remark != "" { + config.Remark = req.Remark + } + + username := c.GetString("username") + if username == "" { + username = "system" + } + config.UpdatedBy = username + config.UpdatedAt = time.Now() + + if err := s.dao.Update(config); err != nil { + result.Failed(c, 500, "更新配置失败: "+err.Error()) + return + } + result.Success(c, config) +} + +func (s *ConfigAnsibleServiceImpl) Delete(c *gin.Context, id uint) { + if err := s.dao.Delete(id); err != nil { + result.Failed(c, 500, "删除配置失败: "+err.Error()) + return + } + result.Success(c, nil) +} + +func (s *ConfigAnsibleServiceImpl) Get(c *gin.Context, id uint) { + config, err := s.dao.GetByID(id) + if err != nil { + result.Failed(c, 404, "配置不存在") + return + } + result.Success(c, config) +} + +func (s *ConfigAnsibleServiceImpl) List(c *gin.Context, page, size int, name string, configType int) { + data, err := s.dao.List(page, size, name, configType) + if err != nil { + result.Failed(c, 500, "获取配置列表失败: "+err.Error()) + return + } + result.Success(c, data) +} \ No newline at end of file diff --git a/api/api/task/service/taskansible.go b/api/api/task/service/taskansible.go index e5067c62..32675337 100644 --- a/api/api/task/service/taskansible.go +++ b/api/api/task/service/taskansible.go @@ -27,69 +27,98 @@ import ( "gorm.io/gorm" ) -// RealTimeLogWriter 实时日志写入器,支持立即刷新到磁盘 -type RealTimeLogWriter struct { - file *os.File +// ITaskAnsibleService 定义Ansible任务服务接口 +type ITaskAnsibleService interface { + CreateTask(c *gin.Context, req *CreateTaskRequest) // 创建任务 + CreateK8sTask(c *gin.Context, req *CreateK8sTaskRequest) // 创建K8s任务 + List(c *gin.Context, page, size int) // 获取任务列表 + StartJob(c *gin.Context, taskID uint) // 启动任务 + StopJob(c *gin.Context, taskID, workID uint) // 停止任务 + GetJobLog(c *gin.Context, taskID, workID uint) // 实时获取任务日志(SSE) + GetJobStatus(c *gin.Context, taskID, workID uint) // 获取任务状态 + GetTaskDetail(c *gin.Context, taskID uint) // 获取任务详情 + GetWorkByID(taskID, workID uint) (*model.TaskAnsibleWork, error) // 获取子任务详情 + DeleteTask(c *gin.Context, taskID uint) // 删除任务 + GetTasksByName(c *gin.Context, name string) // 根据名称模糊查询任务 + GetTasksByType(c *gin.Context, taskType int) // 根据类型查询任务 + GetTasks(c *gin.Context, name string, taskType int, viewName string, page, size int) // 综合查询任务列表 + UpdateTask(c *gin.Context, taskID uint, req *UpdateTaskRequest) // 修改任务 + GetTaskHistoryList(c *gin.Context, taskID uint, page, limit int) // 获取任务历史记录列表 + GetTaskHistoryDetail(c *gin.Context, historyID uint) // 获取任务历史记录详情 + GetTaskHistoryLog(c *gin.Context, historyWorkID uint) // 获取历史任务日志 + GetTaskHistoryLogByDetails(c *gin.Context, taskID, workID, historyID uint) // 获取历史任务日志(通过详细信息) + DeleteTaskHistory(c *gin.Context, historyID uint) // 删除任务历史记录 + ExecuteTask(taskID uint) error // 执行任务 } -// Write 实现io.Writer接口,每次写入后立即刷新到磁盘 -func (w *RealTimeLogWriter) Write(p []byte) (n int, err error) { - n, err = w.file.Write(p) - if err != nil { - return n, err +func NewTaskAnsibleService(db *gorm.DB) ITaskAnsibleService { + return &TaskAnsibleServiceImpl{ + dao: dao.NewTaskAnsibleDao(db), } - // 立即刷新到磁盘,确保SSE能实时读取 - w.file.Sync() - return n, nil -} - -// WriteWithTime 带时间戳的写入 -func (w *RealTimeLogWriter) WriteWithTime(content string) error { - _, err := w.Write([]byte(content)) - return err } -// ITaskAnsibleService 定义Ansible任务服务接口 -type ITaskAnsibleService interface { - CreateTask(c *gin.Context, req *CreateTaskRequest) // 创建任务 - CreateK8sTask(c *gin.Context, req *CreateK8sTaskRequest) // 创建K8s任务 - List(c *gin.Context, page, size int) // 获取任务列表 - StartJob(c *gin.Context, taskID uint) // 启动任务 - StopJob(c *gin.Context, taskID, workID uint) // 停止任务 - GetJobLog(c *gin.Context, taskID, workID uint) // 实时获取任务日志(SSE) - GetJobStatus(c *gin.Context, taskID, workID uint) // 获取任务状态 - GetTaskDetail(c *gin.Context, taskID uint) // 获取任务详情 - GetWorkByID(taskID, workID uint) (*model.TaskAnsibleWork, error) // 获取子任务详情 - DeleteTask(c *gin.Context, taskID uint) // 删除任务 - GetTasksByName(c *gin.Context, name string) // 根据名称模糊查询任务 - GetTasksByType(c *gin.Context, taskType int) // 根据类型查询任务 +// TaskAnsibleServiceImpl 实现Ansible任务服务 +type TaskAnsibleServiceImpl struct { + dao *dao.TaskAnsibleDao } // CreateTaskRequest 创建任务请求参数 type CreateTaskRequest struct { - TaskType int `json:"taskType"` - Name string `json:"name"` - HostGroups map[string][]uint `json:"hostGroups"` - GitRepo string `json:"gitRepo"` - RolesContent []byte `json:"rolesContent"` - PlaybookContents [][]byte `json:"playbookContents"` - Variables map[string]string `json:"variables"` + TaskType int `json:"taskType"` + Name string `json:"name"` + HostGroups map[string][]uint `json:"hostGroups"` + GitRepo string `json:"gitRepo"` + RolesContent []byte `json:"rolesContent"` + PlaybookContents [][]byte `json:"playbookContents"` + PlaybookPaths []string `json:"playbookPaths"` // type=2: 指定多个playbook路径 + Variables map[string]string `json:"variables"` + ExtraVars string `json:"extraVars"` + CliArgs string `json:"cliArgs"` + UseConfig int `json:"useConfig"` + InventoryConfigID *uint `json:"inventoryConfigId"` + GlobalVarsConfigID *uint `json:"globalVarsConfigId"` + ExtraVarsConfigID *uint `json:"extraVarsConfigId"` + CliArgsConfigID *uint `json:"cliArgsConfigId"` + MaxHistoryKeep int `gorm:"default:3;comment:'最大保留历史记录数'"` + CronExpr string `json:"cronExpr"` + IsRecurring int `json:"isRecurring"` + ViewID *uint `json:"viewId"` +} + +// UpdateTaskRequest 修改任务请求参数 +type UpdateTaskRequest struct { + Name string `json:"name"` + HostGroups map[string][]uint `json:"hostGroups"` + GitRepo string `json:"gitRepo"` + PlaybookPaths []string `json:"playbookPaths"` + Variables map[string]string `json:"variables"` + ExtraVars string `json:"extraVars"` + CliArgs string `json:"cliArgs"` + UseConfig int `json:"useConfig"` + InventoryConfigID *uint `json:"inventoryConfigId"` + GlobalVarsConfigID *uint `json:"globalVarsConfigId"` + ExtraVarsConfigID *uint `json:"extraVarsConfigId"` + CliArgsConfigID *uint `json:"cliArgsConfigId"` + MaxHistoryKeep int `gorm:"default:3;comment:'最大保留历史记录数'"` + CronExpr string `json:"cronExpr"` + IsRecurring *int `json:"isRecurring"` + ViewID *uint `json:"viewId"` } // CreateK8sTaskRequest 创建K8s任务请求参数 type CreateK8sTaskRequest struct { - Name string `json:"name"` - Description string `json:"description"` - ClusterName string `json:"cluster_name"` - ClusterVersion string `json:"cluster_version"` - DeploymentMode int `json:"deployment_mode"` - MasterHostIDs []uint `json:"master_host_ids"` - WorkerHostIDs []uint `json:"worker_host_ids"` - EtcdHostIDs []uint `json:"etcd_host_ids"` - EnabledComponents []string `json:"enabled_components"` - PrivateRegistry string `json:"private_registry"` - RegistryUsername string `json:"registry_username"` - RegistryPassword string `json:"registry_password"` + Name string `json:"name"` + Description string `json:"description"` + ClusterName string `json:"cluster_name"` + ClusterVersion string `json:"cluster_version"` + DeploymentMode int `json:"deployment_mode"` + MasterHostIDs []uint `json:"master_host_ids"` + WorkerHostIDs []uint `json:"worker_host_ids"` + EtcdHostIDs []uint `json:"etcd_host_ids"` + EnabledComponents []string `json:"enabled_components"` + PrivateRegistry string `json:"private_registry"` + RegistryUsername string `json:"registry_username"` + RegistryPassword string `json:"registry_password"` RegistryConfig *RegistryConfig `json:"registry_config"` // 新的嵌套配置格式 } @@ -131,17 +160,6 @@ type K8sConfigJSON struct { } `json:"registry,omitempty"` } -// TaskAnsibleServiceImpl 实现Ansible任务服务 -type TaskAnsibleServiceImpl struct { - dao *dao.TaskAnsibleDao -} - -func NewTaskAnsibleService(db *gorm.DB) ITaskAnsibleService { - return &TaskAnsibleServiceImpl{ - dao: dao.NewTaskAnsibleDao(db), - } -} - // HostSSHInfo 主机SSH连接信息 type HostSSHInfo struct { ID uint @@ -150,7 +168,7 @@ type HostSSHInfo struct { User string Password string Key string - AuthType int // 认证类型:1-密码,2-私钥,3-公钥免认证 + AuthType int // 认证类型:1-密码,2-私钥,3-公钥免认证 } // HostSSHInfoCollection 主机信息集合 @@ -159,6 +177,31 @@ type HostSSHInfoCollection struct { HostInfos map[uint]HostSSHInfo } +// RealTimeLogWriter 实时日志写入器,支持立即刷新到磁盘 +type RealTimeLogWriter struct { + file *os.File +} + +// OnTaskConfigChange 任务配置变更钩子 +var OnTaskConfigChange func(task *model.TaskAnsible) + +// Write 实现io.Writer接口,每次写入后立即刷新到磁盘 +func (w *RealTimeLogWriter) Write(p []byte) (n int, err error) { + n, err = w.file.Write(p) + if err != nil { + return n, err + } + // 立即刷新到磁盘,确保SSE能实时读取 + w.file.Sync() + return n, nil +} + +// WriteWithTime 带时间戳的写入 +func (w *RealTimeLogWriter) WriteWithTime(content string) error { + _, err := w.Write([]byte(content)) + return err +} + // GetHostSSHInfo 获取主机SSH信息 func (s *TaskAnsibleServiceImpl) GetHostSSHInfo(hostGroups map[string][]uint) (*HostSSHInfoCollection, error) { // 获取所有唯一主机ID @@ -257,7 +300,7 @@ func (c *HostSSHInfoCollection) GenerateInventory() string { } case 2: // 私钥认证 if host.Key != "" { - builder.WriteString(fmt.Sprintf(" ansible_ssh_private_key_file=%s", host.Key)) + // builder.WriteString(fmt.Sprintf(" ansible_ssh_private_key_file=%s", host.Key)) } case 3: // 公钥免认证 // 不添加额外的认证参数,使用系统默认SSH配置 @@ -304,6 +347,11 @@ func (s *TaskAnsibleServiceImpl) DeleteTask(c *gin.Context, taskID uint) { return } + // 触发任务配置变更钩子 (通知调度器移除任务) + if OnTaskConfigChange != nil { + OnTaskConfigChange(&model.TaskAnsible{ID: taskID, IsRecurring: 0}) + } + // 4. 删除任务相关的文件目录(异步处理,避免影响响应速度) go func() { defer func() { @@ -312,7 +360,7 @@ func (s *TaskAnsibleServiceImpl) DeleteTask(c *gin.Context, taskID uint) { }() // 删除任务目录: task/{taskID}/{taskName} - taskDir := fmt.Sprintf("task/%d/%s", taskID, task.Name) + taskDir := fmt.Sprintf("task/%d", taskID) if _, err := os.Stat(taskDir); err == nil { os.RemoveAll(taskDir) } @@ -340,25 +388,48 @@ func (s *TaskAnsibleServiceImpl) GetJobLog(c *gin.Context, taskID, workID uint) c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("Access-Control-Allow-Origin", "*") - + // 检查认证状态(调试信息) token := c.Query("token") if token == "" || token == "null" { // 对于已完成的任务,即使token为空也允许读取日志 - // sendSSEError(c, "认证失败:token为空") - // return } - // 获取任务记录(仅查询一次) - work, err := s.dao.GetWorkByID(taskID, workID) - if err != nil { - sendSSEError(c, fmt.Sprintf("获取任务记录失败: %v", err)) - return + // 循环尝试获取任务记录,等待 LogPath 生成(最多等待5秒) + var work *taskmodel.TaskAnsibleWork + var err error + maxRetries := 5 + + for i := 0; i < maxRetries; i++ { + work, err = s.dao.GetWorkByID(taskID, workID) + if err != nil { + sendSSEError(c, fmt.Sprintf("获取任务记录失败: %v", err)) + return + } + + // 如果 LogPath 存在,或者任务已经是完成/失败状态,停止等待 + if work.LogPath != "" || work.Status == 3 || work.Status == 4 { + break + } + time.Sleep(1 * time.Second) } // 检查日志路径 if work.LogPath == "" { - sendSSEError(c, "日志文件路径不存在") + // 如果 LogPath 为空,检查是否因为任务启动失败 + if work.Status == 4 { + // 如果子任务有错误信息,发送给前端 + if work.ErrorMsg != "" { + sendSSEError(c, fmt.Sprintf("任务执行失败: %s", work.ErrorMsg)) + } else { + // 获取父任务查看是否有全局错误 + task, _ := s.dao.GetTaskDetail(taskID) + // 假设父任务也没有详细信息 + sendSSEError(c, fmt.Sprintf("任务启动失败,详情请查看任务状态 (TaskStatus=%d)", task.Status)) + } + } else { + sendSSEError(c, "日志文件路径尚未生成,请稍后重试") + } return } @@ -370,13 +441,11 @@ func (s *TaskAnsibleServiceImpl) GetJobLog(c *gin.Context, taskID, workID uint) // 如果是相对路径,转换为绝对路径 // 获取当前工作目录 cwd, _ := os.Getwd() - // 检查是否在任务子目录中,如果是则返回到项目根目录 + // 检查是否在任务子目录中,如果是则返回到项目根目录(防御性编程) if strings.Contains(cwd, "/task/") { - // 切换到项目根目录计算绝对路径 projectRoot := strings.Split(cwd, "/task/")[0] logPath = filepath.Join(projectRoot, work.LogPath) } else { - // 已经在项目根目录 logPath = filepath.Join(cwd, work.LogPath) } } @@ -421,7 +490,7 @@ func (s *TaskAnsibleServiceImpl) GetJobLog(c *gin.Context, taskID, workID uint) // 读取完整的日志文件内容 lineCount := 0 batchSize := 10 // 每10行flush一次,平衡性能和实时性 - + for { line, err := reader.ReadString('\n') if err == io.EOF { @@ -432,7 +501,7 @@ func (s *TaskAnsibleServiceImpl) GetJobLog(c *gin.Context, taskID, workID uint) return } lineCount++ - + // 发送日志内容 (确保非空行才发送) trimmed := strings.TrimSpace(line) if trimmed != "" { @@ -441,7 +510,7 @@ func (s *TaskAnsibleServiceImpl) GetJobLog(c *gin.Context, taskID, workID uint) // 发送空行 fmt.Fprintf(c.Writer, "data: \n\n") } - + // 批量flush,减少网络开销 if lineCount%batchSize == 0 { if flusher, ok := c.Writer.(http.Flusher); ok { @@ -453,10 +522,10 @@ func (s *TaskAnsibleServiceImpl) GetJobLog(c *gin.Context, taskID, workID uint) return } } - + lastPos, _ = file.Seek(0, io.SeekCurrent) } - + // 最后flush剩余数据 if flusher, ok := c.Writer.(http.Flusher); ok { flusher.Flush() @@ -608,12 +677,39 @@ func (s *TaskAnsibleServiceImpl) GetTaskDetail(c *gin.Context, taskID uint) { } } - // 构建精简的任务信息 + // 解析HostGroups + var hostGroups map[string][]uint + json.Unmarshal([]byte(task.HostGroups), &hostGroups) + + // 解析GlobalVars + var variables map[string]string + json.Unmarshal([]byte(task.GlobalVars), &variables) + + // 构建完整的任务信息 taskInfo := gin.H{ - "ID": task.ID, // 父任务ID - "Name": task.Name, // 父任务名称 - "TaskCount": task.TaskCount, // 子任务数量 - "Works": works, // 子任务列表 + "ID": task.ID, + "Name": task.Name, + "Type": task.Type, + "Description": task.Description, + "GitRepo": task.GitRepo, + "HostGroups": hostGroups, + "GlobalVars": variables, + "ExtraVars": task.ExtraVars, + "CliArgs": task.CliArgs, + "Status": task.Status, + "TaskCount": task.TaskCount, + "TotalDuration": task.TotalDuration, + "UseConfig": task.UseConfig, + "InventoryConfigID": task.InventoryConfigID, + "GlobalVarsConfigID": task.GlobalVarsConfigID, + "ExtraVarsConfigID": task.ExtraVarsConfigID, + "CliArgsConfigID": task.CliArgsConfigID, + "IsRecurring": task.IsRecurring, + "CronExpr": task.CronExpr, + "MaxHistoryKeep": task.MaxHistoryKeep, + "CreatedAt": task.CreatedAt, + "UpdatedAt": task.UpdatedAt, + "Works": works, } result.Success(c, gin.H{ @@ -626,53 +722,142 @@ func (s *TaskAnsibleServiceImpl) GetWorkByID(taskID, workID uint) (*model.TaskAn return s.dao.GetWorkByID(taskID, workID) } +// GetTasks 查询任务列表 +func (s *TaskAnsibleServiceImpl) GetTasks(c *gin.Context, name string, taskType int, viewName string, page, size int) { + tasks, total, err := s.dao.GetTasks(name, taskType, viewName, page, size) + if err != nil { + result.Failed(c, 500, "查询任务列表失败: "+err.Error()) + return + } + result.Success(c, gin.H{"data": tasks, "total": total}) +} + // StartJob 启动任务 func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { + if err := s.ExecuteTask(taskID); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "任务已开始执行"}) +} + +// ExecuteTask 执行任务 +func (s *TaskAnsibleServiceImpl) ExecuteTask(taskID uint) error { // 1. 获取任务详情(包含子任务) task, err := s.dao.GetTaskDetail(taskID) if err != nil { - c.JSON(500, gin.H{"error": fmt.Sprintf("获取任务失败: %v", err)}) - return + return fmt.Errorf("获取任务失败: %v", err) } // 检查任务是否存在子任务 if len(task.Works) == 0 { - c.JSON(400, gin.H{"error": "任务没有子任务,无法执行"}) - return + return fmt.Errorf("任务没有子任务,无法执行") } // 2. 更新任务状态为运行中(状态=2) if err := s.dao.DB.Model(&model.TaskAnsible{}).Where("id = ?", taskID).Update("status", 2).Error; err != nil { - c.JSON(500, gin.H{"error": fmt.Sprintf("更新任务状态失败: %v", err)}) - return + return fmt.Errorf("更新任务状态失败: %v", err) } - // 3. 异步执行Ansible任务(优化版本 - 直接执行,无需重复查询) + // 3. 异步执行Ansible任务 go func() { defer func() { if r := recover(); r != nil { - s.updateTaskErrorStatus(taskID, fmt.Errorf("任务执行异常: %v", r)) + errMsg := fmt.Sprintf("任务执行异常: %v", r) + s.updateTaskErrorStatus(taskID, fmt.Errorf("%s", errMsg)) + // 同时更新所有子任务状态为失败 + s.dao.DB.Model(&model.TaskAnsibleWork{}).Where("task_id = ?", taskID). + Updates(map[string]interface{}{"status": 4, "error_msg": errMsg}) } }() - // 构建任务目录路径:task/{taskID}/{taskName} - taskDir := fmt.Sprintf("task/%d/%s", taskID, task.Name) + // 获取当前工作目录 + workDir, _ := os.Getwd() + // 防御性处理:防止WD在task子目录中 + if strings.Contains(workDir, "/task/") { + workDir = strings.Split(workDir, "/task/")[0] + } + + // 构建任务目录相对路径 + taskRelDir := fmt.Sprintf("task/%d/%s", taskID, task.Name) + // 构建任务目录绝对路径 + absTaskDir := filepath.Join(workDir, taskRelDir) // 检查任务目录是否存在 - if _, err := os.Stat(taskDir); os.IsNotExist(err) { - s.updateTaskErrorStatus(taskID, fmt.Errorf("任务目录不存在: %s", taskDir)) + if _, err := os.Stat(absTaskDir); os.IsNotExist(err) { + errMsg := fmt.Sprintf("任务目录不存在: %s (请尝试删除并重新创建任务)", absTaskDir) + s.updateTaskErrorStatus(taskID, fmt.Errorf("%s", errMsg)) + s.dao.DB.Model(&model.TaskAnsibleWork{}).Where("task_id = ?", taskID). + Updates(map[string]interface{}{"status": 4, "error_msg": errMsg}) return } - // 获取当前工作目录 - originalDir, _ := os.Getwd() + // Inventory文件绝对路径 + inventoryPath := filepath.Join(absTaskDir, "hosts") + + // 如果启用配置中心且指定了inventory配置,则覆盖hosts文件 + if task.UseConfig == 1 && task.InventoryConfigID != nil { + var cfg taskmodel.ConfigAnsible + if err := s.dao.DB.First(&cfg, *task.InventoryConfigID).Error; err == nil { + // 写入配置中心的Inventory内容 + if err := os.WriteFile(inventoryPath, []byte(cfg.Content), 0644); err != nil { + errMsg := fmt.Sprintf("写入Inventory配置失败: %v", err) + s.updateTaskErrorStatus(taskID, fmt.Errorf("%s", errMsg)) + s.dao.DB.Model(&model.TaskAnsibleWork{}).Where("task_id = ?", taskID). + Updates(map[string]interface{}{"status": 4, "error_msg": errMsg}) + return + } + } + } + + // 如果启用配置中心且指定了GlobalVars配置,则覆盖vars/all.yml文件 + if task.UseConfig == 1 && task.GlobalVarsConfigID != nil { + var cfg taskmodel.ConfigAnsible + if err := s.dao.DB.First(&cfg, *task.GlobalVarsConfigID).Error; err == nil { + varsFile := filepath.Join(absTaskDir, "vars/all.yml") + if err := os.MkdirAll(filepath.Dir(varsFile), 0755); err != nil { + errMsg := fmt.Sprintf("创建变量目录失败: %v", err) + s.updateTaskErrorStatus(taskID, fmt.Errorf("%s", errMsg)) + s.dao.DB.Model(&model.TaskAnsibleWork{}).Where("task_id = ?", taskID). + Updates(map[string]interface{}{"status": 4, "error_msg": errMsg}) + return + } + if err := os.WriteFile(varsFile, []byte(cfg.Content), 0644); err != nil { + errMsg := fmt.Sprintf("写入GlobalVars配置失败: %v", err) + s.updateTaskErrorStatus(taskID, fmt.Errorf("%s", errMsg)) + s.dao.DB.Model(&model.TaskAnsibleWork{}).Where("task_id = ?", taskID). + Updates(map[string]interface{}{"status": 4, "error_msg": errMsg}) + return + } + } + } + + // 获取ExtraVars + extraVars := task.ExtraVars + if task.UseConfig == 1 && task.ExtraVarsConfigID != nil { + var cfg taskmodel.ConfigAnsible + if err := s.dao.DB.First(&cfg, *task.ExtraVarsConfigID).Error; err == nil { + extraVars = cfg.Content + } + } + + // 获取CliArgs + cliArgsStr := task.CliArgs + if task.UseConfig == 1 && task.CliArgsConfigID != nil { + var cfg taskmodel.ConfigAnsible + if err := s.dao.DB.First(&cfg, *task.CliArgsConfigID).Error; err == nil { + cliArgsStr = cfg.Content + } + } // 执行每个子任务 allSuccess := true for _, work := range task.Works { // 创建日志目录(使用绝对路径) - absLogDir := filepath.Join(originalDir, fmt.Sprintf("logs/ansible/%d/%d", taskID, work.ID)) + // 使用时间戳作为唯一ID,隔离每次执行的日志 + runID := time.Now().Format("20060102150405") + absLogDir := filepath.Join(workDir, fmt.Sprintf("logs/ansible/%d/%d/%s", taskID, work.ID, runID)) if err := os.MkdirAll(absLogDir, 0755); err != nil { s.updateTaskErrorStatus(taskID, fmt.Errorf("创建日志目录失败: %v", err)) return @@ -684,11 +869,12 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { if task.Type == 3 { logFileName = "deploy-simple.sh" } else { - logFileName = work.EntryFileName + // 使用Base获取文件名,防止EntryFileName包含路径导致日志创建目录失败 + logFileName = filepath.Base(work.EntryFileName) } absLogPath := filepath.Join(absLogDir, fmt.Sprintf("%s.log", logFileName)) // 用于数据库存储的相对路径 - relativeLogPath := fmt.Sprintf("logs/ansible/%d/%d/%s.log", taskID, work.ID, logFileName) + relativeLogPath := fmt.Sprintf("logs/ansible/%d/%d/%s/%s.log", taskID, work.ID, runID, logFileName) // 更新子任务状态为运行中,记录开始时间和日志路径 workStartTime := time.Now() @@ -700,20 +886,14 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { "log_path": relativeLogPath, // 使用相对路径存储到数据库 }) - // 切换到任务目录 - if err := os.Chdir(taskDir); err != nil { - s.updateWorkErrorStatus(work.ID, fmt.Errorf("切换到任务目录失败: %v", err)) - allSuccess = false - continue - } - // 检查playbook文件是否存在(K8s任务跳过此检查) var playbookPath string if task.Type != 3 { playbookPath = work.EntryFileName - if _, err := os.Stat(playbookPath); os.IsNotExist(err) { - os.Chdir(originalDir) - s.updateWorkErrorStatus(work.ID, fmt.Errorf("Playbook文件不存在: %s", playbookPath)) + // 检查绝对路径 + absPlaybookPath := filepath.Join(absTaskDir, playbookPath) + if _, err := os.Stat(absPlaybookPath); os.IsNotExist(err) { + s.updateWorkErrorStatus(work.ID, fmt.Errorf("Playbook文件不存在: %s", absPlaybookPath)) allSuccess = false continue } @@ -725,8 +905,7 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { var cmdArgs []string if task.Type == 3 { // K8s任务 // 检查config.json文件是否存在 - if _, err := os.Stat("config.json"); os.IsNotExist(err) { - os.Chdir(originalDir) + if _, err := os.Stat(filepath.Join(absTaskDir, "config.json")); os.IsNotExist(err) { s.updateWorkErrorStatus(work.ID, fmt.Errorf("config.json文件不存在,任务创建可能有问题")) allSuccess = false continue @@ -734,8 +913,7 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { // 检查部署脚本是否存在 scriptPath := filepath.Join("scripts", "deploy-simple.sh") - if _, err := os.Stat(scriptPath); os.IsNotExist(err) { - os.Chdir(originalDir) + if _, err := os.Stat(filepath.Join(absTaskDir, scriptPath)); os.IsNotExist(err) { s.updateWorkErrorStatus(work.ID, fmt.Errorf("K8s部署脚本不存在: %s", scriptPath)) allSuccess = false continue @@ -746,24 +924,43 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { } else { // Ansible任务 // 检查hosts文件是否存在(创建任务时已生成) - if _, err := os.Stat("hosts"); os.IsNotExist(err) { - os.Chdir(originalDir) + if _, err := os.Stat(filepath.Join(absTaskDir, "hosts")); os.IsNotExist(err) { s.updateWorkErrorStatus(work.ID, fmt.Errorf("hosts文件不存在,任务创建可能有问题")) allSuccess = false continue } // 构建Ansible命令 - cmdArgs = []string{"ansible-playbook", "-i", "hosts", playbookPath, "-v"} + cmdArgs = []string{"ansible-playbook", "-i", "hosts"} + + // 检查 vars/all.yml 是否存在,如果存在则显式加载 + // 用户反馈 vars/all.yml 未生效,通过 --extra-vars 强制加载 + varsFile := "vars/all.yml" + if _, err := os.Stat(filepath.Join(absTaskDir, varsFile)); err == nil { + cmdArgs = append(cmdArgs, "--extra-vars", "@"+varsFile) + } + + // 添加ExtraVars参数 + if extraVars != "" { + cmdArgs = append(cmdArgs, "--extra-vars", extraVars) + } + + // 添加CliArgs参数 + if cliArgsStr != "" { + cmdArgs = append(cmdArgs, strings.Fields(cliArgsStr)...) + } + + // 添加Playbook路径 + cmdArgs = append(cmdArgs, playbookPath, "-v") } // 执行命令 cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + cmd.Dir = absTaskDir // 设置命令执行目录,替代 os.Chdir // 创建日志文件用于实时写入(使用绝对路径) logFile, err := os.OpenFile(absLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { - os.Chdir(originalDir) s.updateWorkErrorStatus(work.ID, fmt.Errorf("创建日志文件失败: %v", err)) allSuccess = false continue @@ -772,7 +969,11 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { // 写入命令信息到日志文件 logFile.WriteString(fmt.Sprintf("[%s] 开始执行任务\n", time.Now().Format("2006-01-02 15:04:05"))) logFile.WriteString(fmt.Sprintf("命令: %s\n", strings.Join(cmdArgs, " "))) - logFile.WriteString(fmt.Sprintf("工作目录: %s\n", taskDir)) + logFile.WriteString(fmt.Sprintf("工作目录: %s\n", absTaskDir)) + logFile.WriteString(fmt.Sprintf("Inventory: %s\n", inventoryPath)) + if extraVars != "" { + logFile.WriteString(fmt.Sprintf("Extra Variables: %s\n", extraVars)) + } logFile.WriteString("==========================================\n") logFile.Sync() // 立即刷新到磁盘 @@ -797,9 +998,6 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { } logFile.Close() - // 切换回原目录 - os.Chdir(originalDir) - // 计算执行耗时 workEndTime := time.Now() duration := int(workEndTime.Sub(workStartTime).Seconds()) @@ -856,10 +1054,62 @@ func (s *TaskAnsibleServiceImpl) StartJob(c *gin.Context, taskID uint) { "total_duration": totalDuration, "updated_at": time.Now(), }) + + // --- 保存历史记录 --- + uniqId := fmt.Sprintf("%d-%d", taskID, time.Now().Unix()) + + // 创建主历史记录 + history := &model.TaskAnsibleHistory{ + TaskID: taskID, + UniqId: uniqId, + Status: finalStatus, + TotalDuration: int(totalDuration), + CreatedAt: time.Now(), + Trigger: 1, // 默认为手动 + } + s.dao.CreateTaskAnsibleHistory(history) + + // 创建子任务历史记录 + var workHistories []model.TaskAnsibleworkHistory + for _, w := range works { + // LogPath is relative, join with workDir to get absolute path check if needed, + // but here we just store the relative or whatever path we used for the active job. + // However, if we want to read it later, we need to know where it is. + // The previous logic used 'relativeLogPath' to store in DB. + // Let's verify what `w.LogPath` contains. It contains `logs/ansible/...`. + // To be safe and since we want to avoid DB size bloat, we store the path. + + workHistories = append(workHistories, model.TaskAnsibleworkHistory{ + HistoryID: history.ID, + TaskID: taskID, + WorkID: w.ID, + HostName: w.EntryFileName, // Playbook name + Status: w.Status, + LogPath: w.LogPath, // Save relative path: logs/ansible/taskID/workID/xxx.log + Duration: w.Duration, + CreatedAt: time.Now(), + }) + } + + if len(workHistories) > 0 { + s.dao.CreateTaskAnsibleworkHistories(workHistories) + } + + // 清理旧历史记录 + // 获取MaxHistoryKeep + var currentTask model.TaskAnsible + if err := s.dao.DB.First(¤tTask, taskID).Error; err == nil { + maxKeep := currentTask.MaxHistoryKeep + if maxKeep <= 0 { + maxKeep = 3 // 默认3条 + } + s.dao.DeleteOldHistory(taskID, maxKeep) + } + // --- 历史记录保存结束 --- } }() - c.JSON(200, gin.H{"message": "任务已开始执行"}) + return nil } // updateTaskErrorStatus 更新任务为错误状态 @@ -916,30 +1166,46 @@ func (s *TaskAnsibleServiceImpl) CreateTask(c *gin.Context, req *CreateTaskReque return } - // 获取主机信息 - hostInfos, err := s.GetHostSSHInfo(hostGroups) - if err != nil { - result.Failed(c, 500, err.Error()) - return - } - + hostInfos := &HostSSHInfoCollection{} // 获取所有主机ID allHostIDs := make([]uint, 0) - for _, ids := range hostGroups { - for _, id := range ids { - if id > 0 { // 确保ID有效 - allHostIDs = append(allHostIDs, id) - } - } - } + if req.UseConfig == 1 { + // 如果启用配置中心,检查相关配置是否存在 + } else { + // 获取主机信息 + var err error + hostInfos, err = s.GetHostSSHInfo(hostGroups) + if err != nil { + result.Failed(c, 500, err.Error()) + return + } + for _, ids := range hostGroups { + for _, id := range ids { + if id > 0 { // 确保ID有效 + allHostIDs = append(allHostIDs, id) + } + } + } + } // 创建任务记录 task := &taskmodel.TaskAnsible{ - Name: name, - Type: taskType, // 1=手动,2=Git导入 - HostGroups: toJSON(hostGroups), - AllHostIDs: toJSON(allHostIDs), - Status: 1, // 1表示等待中 + Name: name, + Type: taskType, // 1=手动,2=Git导入 + HostGroups: toJSON(hostGroups), + AllHostIDs: toJSON(allHostIDs), + Status: 1, // 1表示等待中 + ExtraVars: req.ExtraVars, + CliArgs: req.CliArgs, + UseConfig: req.UseConfig, + InventoryConfigID: req.InventoryConfigID, + GlobalVarsConfigID: req.GlobalVarsConfigID, + ExtraVarsConfigID: req.ExtraVarsConfigID, + CliArgsConfigID: req.CliArgsConfigID, + MaxHistoryKeep: req.MaxHistoryKeep, + CronExpr: req.CronExpr, + IsRecurring: req.IsRecurring, + ViewID: req.ViewID, } // 如果是Git任务,设置仓库地址 @@ -969,7 +1235,7 @@ func (s *TaskAnsibleServiceImpl) CreateTask(c *gin.Context, req *CreateTaskReque } } else if taskType == 2 { // Type=2: Git导入任务 - if err := s.handleGitTask(c, task, projectDir, hostInfos, gitRepo, variables); err != nil { + if err := s.handleGitTask(c, task, projectDir, hostInfos, gitRepo, variables, req.PlaybookPaths); err != nil { result.Failed(c, 500, err.Error()) return } @@ -985,6 +1251,11 @@ func (s *TaskAnsibleServiceImpl) CreateTask(c *gin.Context, req *CreateTaskReque return } + // 触发任务配置变更钩子 + if OnTaskConfigChange != nil { + OnTaskConfigChange(updatedTask) + } + result.Success(c, updatedTask) } @@ -1154,17 +1425,33 @@ func (s *TaskAnsibleServiceImpl) handleManualTask(c *gin.Context, task *taskmode } // handleGitTask 处理Git导入的任务(Type=2) -func (s *TaskAnsibleServiceImpl) handleGitTask(c *gin.Context, task *taskmodel.TaskAnsible, projectDir string, hostInfos *HostSSHInfoCollection, gitRepo string, variables map[string]string) error { +func (s *TaskAnsibleServiceImpl) handleGitTask(c *gin.Context, task *taskmodel.TaskAnsible, projectDir string, hostInfos *HostSSHInfoCollection, gitRepo string, variables map[string]string, manualPlaybookPaths []string) error { // 1. 下载Git仓库 if err := s.cloneGitRepository(gitRepo, projectDir); err != nil { return fmt.Errorf("下载Git仓库失败: %v", err) } - // 2. 解析仓库目录结构,识别playbook文件 - playbookFiles, err := s.parseGitRepository(projectDir) - if err != nil { - return fmt.Errorf("解析Git仓库失败: %v", err) + // 2. 确定playbook文件列表 + var playbookFiles []string + if len(manualPlaybookPaths) > 0 { + // 如果前端指定了playbook列表,验证并使用它们 + var err error + playbookFiles, err = s.resolvePlaybookPaths(projectDir, manualPlaybookPaths) + if err != nil { + return err + } + } else { + // 否则自动扫描仓库目录结构 + var err error + playbookFiles, err = s.parseGitRepository(projectDir) + if err != nil { + return fmt.Errorf("解析Git仓库失败: %v", err) + } + } + + if len(playbookFiles) == 0 { + return fmt.Errorf("未找到有效的playbook文件") } // 3. 创建子任务记录 @@ -1188,6 +1475,42 @@ func (s *TaskAnsibleServiceImpl) handleGitTask(c *gin.Context, task *taskmodel.T return nil } +// resolvePlaybookPaths 校验并解析仓库内playbook路径 +func (s *TaskAnsibleServiceImpl) resolvePlaybookPaths(projectDir string, paths []string) ([]string, error) { + var result []string + for _, p := range paths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // 防止路径遍历攻击 + clean := filepath.Clean(p) + if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { + return nil, fmt.Errorf("非法playbook路径: %s (禁止包含..或使用绝对路径)", p) + } + + // 拼接完整路径 + full := filepath.Join(projectDir, clean) + + // 验证文件扩展名 + ext := strings.ToLower(filepath.Ext(full)) + if ext != ".yml" && ext != ".yaml" { + return nil, fmt.Errorf("playbook必须是yml/yaml文件: %s", p) + } + + // 验证文件是否存在 + if _, err := os.Stat(full); err != nil { + return nil, fmt.Errorf("playbook不存在: %s", p) + } + + // 注意:这里应该只返回相对路径,而不是包含项目目录的完整路径 + // 因为后续 createSubTasksFromPlaybooks 会再次拼接项目目录 + result = append(result, clean) + } + return result, nil +} + // cloneGitRepository 克隆Git仓库 func (s *TaskAnsibleServiceImpl) cloneGitRepository(gitRepo, projectDir string) error { // 使用git命令克隆仓库 @@ -1355,7 +1678,7 @@ func (s *TaskAnsibleServiceImpl) CreateK8sTask(c *gin.Context, req *CreateK8sTas task := &model.TaskAnsible{ Name: req.Name, Description: req.Description, - Type: 3, // K8s任务类型 + Type: 3, // K8s任务类型 GitRepo: "git@gitee.com:zhang_fan1024/zf-k8s.git", // 固定的K8s Git仓库 HostGroups: s.buildK8sHostGroups(req), AllHostIDs: s.buildK8sAllHostIDs(req), @@ -1598,3 +1921,241 @@ func (s *TaskAnsibleServiceImpl) createK8sSubTask(taskID uint, projectDir string return nil } + +// UpdateTask 修改任务 +func (s *TaskAnsibleServiceImpl) UpdateTask(c *gin.Context, taskID uint, req *UpdateTaskRequest) { + // 1. 获取任务 + task, err := s.dao.GetTaskDetail(taskID) + if err != nil { + result.Failed(c, 500, fmt.Sprintf("获取任务失败: %v", err)) + return + } + + // 2. 检查任务状态,运行中不可修改 + if task.Status == 2 { + result.Failed(c, 400, "任务正在运行中,无法修改") + return + } + + // 3. 更新基本信息 + if req.Name != "" { + task.Name = req.Name + } + + // 更新配置选项 + task.UseConfig = req.UseConfig + if req.InventoryConfigID != nil { + task.InventoryConfigID = req.InventoryConfigID + } + if req.GlobalVarsConfigID != nil { + task.GlobalVarsConfigID = req.GlobalVarsConfigID + } + if req.ExtraVarsConfigID != nil { + task.ExtraVarsConfigID = req.ExtraVarsConfigID + } + if req.CliArgsConfigID != nil { + task.CliArgsConfigID = req.CliArgsConfigID + } + + + // 更新变量信息 + if req.ExtraVars != "" { + task.ExtraVars = req.ExtraVars + } + if req.CliArgs != "" { + task.CliArgs = req.CliArgs + } + + // 4. 更新Git信息 (仅Type=2) + if task.Type == 2 && req.GitRepo != "" { + task.GitRepo = req.GitRepo + } + + // 5. 更新HostGroups + if len(req.HostGroups) > 0 { + task.HostGroups = toJSON(req.HostGroups) + // 重新计算AllHostIDs + allHostIDs := make([]uint, 0) + idMap := make(map[uint]bool) + for _, ids := range req.HostGroups { + for _, id := range ids { + if id > 0 && !idMap[id] { + idMap[id] = true + allHostIDs = append(allHostIDs, id) + } + } + } + task.AllHostIDs = toJSON(allHostIDs) + } + + // 6. 更新GlobalVars + if len(req.Variables) > 0 { + task.GlobalVars = toJSON(req.Variables) + } + + // Update New fields (支持增量更新) + // 只有当 CronExpr 不为空字符串时才更新 + if req.CronExpr != "" { + task.CronExpr = req.CronExpr + } + + // 只有当 IsRecurring 传了值(不为nil)时才更新 + if req.IsRecurring != nil { + task.IsRecurring = *req.IsRecurring + } + + if req.MaxHistoryKeep > 0 { + task.MaxHistoryKeep = req.MaxHistoryKeep + } + + // 只有当 ViewID 传了值(不为nil)时才更新 + if req.ViewID != nil { + task.ViewID = req.ViewID + } + + task.UpdatedAt = time.Now() + + // 7. 保存 + if err := s.dao.Update(task); err != nil { + result.Failed(c, 500, fmt.Sprintf("更新任务失败: %v", err)) + return + } + + // 触发任务配置变更钩子 + if OnTaskConfigChange != nil { + // 重新获取完整任务信息以确保调度器获取最新配置 + if fullTask, err := s.dao.GetTaskDetail(taskID); err == nil { + OnTaskConfigChange(fullTask) + } + } + + result.Success(c, task) +} + +// GetTaskHistoryList 获取任务历史记录列表 Service +func (s *TaskAnsibleServiceImpl) GetTaskHistoryList(c *gin.Context, taskID uint, page, limit int) { + histories, total, err := s.dao.GetTaskAnsibleHistoryList(taskID, page, limit) + if err != nil { + result.Failed(c, 500, fmt.Sprintf("获取历史记录列表失败: %v", err)) + return + } + result.Success(c, gin.H{ + "data": histories, + "total": total, + }) +} + +// GetTaskHistoryDetail 获取任务历史记录详情 Service +func (s *TaskAnsibleServiceImpl) GetTaskHistoryDetail(c *gin.Context, historyID uint) { + history, err := s.dao.GetTaskAnsibleHistoryDetail(historyID) + if err != nil { + result.Failed(c, 500, fmt.Sprintf("获取历史记录详情失败: %v", err)) + return + } + result.Success(c, history) +} + +// GetTaskHistoryLog 获取历史记录的日志内容 +func (s *TaskAnsibleServiceImpl) GetTaskHistoryLog(c *gin.Context, historyWorkID uint) { + // 1. 获取SubHistory记录 + var workHistory model.TaskAnsibleworkHistory + if err := s.dao.DB.First(&workHistory, historyWorkID).Error; err != nil { + result.Failed(c, 404, "未找到历史任务日志记录") + return + } + + // 2. 获取LogPath + logPath := workHistory.LogPath + if logPath == "" { + result.Failed(c, 404, "日志路径为空") + return + } + + // 3. 构建绝对路径 (假设运行目录在项目根目录) + // LogPath is usually "logs/ansible/..." + workDir, _ := os.Getwd() + // 防御性处理,如果已经在 task 目录下 + if strings.Contains(workDir, "/task/") { + workDir = strings.Split(workDir, "/task/")[0] + } + absLogPath := filepath.Join(workDir, logPath) + + // 4. 读取文件 + content, err := os.ReadFile(absLogPath) + if err != nil { + result.Failed(c, 500, fmt.Sprintf("读取日志文件失败: %v", err)) + return + } + + result.Success(c, string(content)) +} + +// GetTaskHistoryLogByDetails 根据 TaskID, WorkID, HistoryID 获取日志 +func (s *TaskAnsibleServiceImpl) GetTaskHistoryLogByDetails(c *gin.Context, taskID, workID, historyID uint) { + // 1. 查询 TaskAnsibleworkHistory + var workHistory model.TaskAnsibleworkHistory + err := s.dao.DB.Where("task_id = ? AND work_id = ? AND history_id = ?", taskID, workID, historyID). + First(&workHistory).Error + + if err != nil { + result.Failed(c, 404, "未找到历史日志记录") + return + } + + // 2. 获取LogPath + logPath := workHistory.LogPath + if logPath == "" { + result.Failed(c, 404, "日志路径为空") + return + } + + // 3. 读取文件 + workDir, _ := os.Getwd() + if strings.Contains(workDir, "/task/") { + workDir = strings.Split(workDir, "/task/")[0] + } + absLogPath := filepath.Join(workDir, logPath) + + content, err := os.ReadFile(absLogPath) + if err != nil { + result.Failed(c, 500, fmt.Sprintf("读取日志文件失败: %v", err)) + return + } + + result.Success(c, string(content)) +} + +// DeleteTaskHistory 删除任务历史记录 +func (s *TaskAnsibleServiceImpl) DeleteTaskHistory(c *gin.Context, historyID uint) { + // 1. 获取History记录 + history, err := s.dao.GetTaskAnsibleHistoryDetail(historyID) + if err != nil { + result.Failed(c, 404, "未找到历史记录") + return + } + + // 2. 删除文件 (RunID目录) + for _, work := range history.WorkHistories { + if work.LogPath != "" { + workDir, _ := os.Getwd() + if strings.Contains(workDir, "/task/") { + workDir = strings.Split(workDir, "/task/")[0] + } + absLogPath := filepath.Join(workDir, work.LogPath) + dirToDelete := filepath.Dir(absLogPath) + + // 安全检查:确保要删除的目录在 logs/ansible 之下 + if strings.Contains(dirToDelete, "logs/ansible") && len(dirToDelete) > 12 { + os.RemoveAll(dirToDelete) + } + } + } + + // 3. 删除数据库记录 + if err := s.dao.DeleteHistory(historyID); err != nil { + result.Failed(c, 500, fmt.Sprintf("删除历史记录失败: %v", err)) + return + } + + result.Success(c, gin.H{"message": "删除成功", "id": historyID}) +} \ No newline at end of file diff --git a/api/pkg/db/migrate.go b/api/pkg/db/migrate.go index 0a3006ed..44da77c2 100644 --- a/api/pkg/db/migrate.go +++ b/api/pkg/db/migrate.go @@ -29,6 +29,10 @@ var models = []interface{}{ &taskmodel.TaskWork{}, &taskmodel.TaskAnsible{}, &taskmodel.TaskAnsibleWork{}, + &taskmodel.ConfigAnsible{}, + &taskmodel.TaskAnsibleHistory{}, + &taskmodel.TaskAnsibleworkHistory{}, + &taskmodel.TaskAnsibleView{}, &monitormodel.Agent{}, &k8smodel.KubeCluster{}, &appmodel.Application{}, diff --git a/api/router/k8s/k8s.go b/api/router/k8s/k8s.go index 5e9a9538..0e378cbe 100644 --- a/api/router/k8s/k8s.go +++ b/api/router/k8s/k8s.go @@ -19,6 +19,7 @@ func RegisterK8sRoutes(router *gin.RouterGroup) { k8sIngressCtrl := controller.NewK8sIngressController(common.GetDB()) k8sStorageCtrl := controller.NewK8sStorageController(common.GetDB()) k8sConfigCtrl := controller.NewK8sConfigController(common.GetDB()) + k8sCRDCtrl := controller.NewK8sCRDController(common.GetDB()) // K8s集群管理路由 router.POST("/k8s/cluster", middleware.AuthMiddleware(), kubeClusterCtrl.CreateCluster) // 创建集群 @@ -214,4 +215,15 @@ func RegisterK8sRoutes(router *gin.RouterGroup) { // Secret YAML管理路由 router.GET("/k8s/cluster/:id/namespaces/:namespaceName/secrets/:secretName/yaml", middleware.AuthMiddleware(), k8sConfigCtrl.GetSecretYaml) // 获取Secret YAML router.PUT("/k8s/cluster/:id/namespaces/:namespaceName/secrets/:secretName/yaml", middleware.AuthMiddleware(), k8sConfigCtrl.UpdateSecretYaml) // 更新Secret YAML + + // ===================== K8s CRD管理路由 ===================== + router.GET("/k8s/cluster/:id/crds/groups", middleware.AuthMiddleware(), k8sCRDCtrl.GetCRDGroups) // 获取CRD API Group列表 + router.GET("/k8s/cluster/:id/crds", middleware.AuthMiddleware(), k8sCRDCtrl.GetCRDList) // 获取CRD列表 + router.GET("/k8s/cluster/:id/namespaces/:namespaceName/crds/:crdName/resources", middleware.AuthMiddleware(), k8sCRDCtrl.GetCustomResourceList) // 获取自定义资源列表 + router.GET("/k8s/cluster/:id/namespaces/:namespaceName/crds/:crdName/resources/:crName", middleware.AuthMiddleware(), k8sCRDCtrl.GetCustomResourceDetail) // 获取自定义资源详情 + router.POST("/k8s/cluster/:id/namespaces/:namespaceName/crds/:crdName/resources", middleware.AuthMiddleware(), k8sCRDCtrl.CreateCustomResource) // 创建自定义资源 + router.DELETE("/k8s/cluster/:id/namespaces/:namespaceName/crds/:crdName/resources/:crName", middleware.AuthMiddleware(), k8sCRDCtrl.DeleteCustomResource) // 删除自定义资源 + router.GET("/k8s/cluster/:id/namespaces/:namespaceName/crds/:crdName/resources/:crName/yaml", middleware.AuthMiddleware(), k8sCRDCtrl.GetCustomResourceYaml) // 获取自定义资源 YAML + router.PUT("/k8s/cluster/:id/namespaces/:namespaceName/crds/:crdName/resources/:crName/yaml", middleware.AuthMiddleware(), k8sCRDCtrl.UpdateCustomResourceYaml) // 更新自定义资源 YAML + } \ No newline at end of file diff --git a/api/router/task/task.go b/api/router/task/task.go index 4ca19eb0..e332b0c5 100644 --- a/api/router/task/task.go +++ b/api/router/task/task.go @@ -21,18 +21,18 @@ func RegisterTaskRoutes(router *gin.RouterGroup) { router.GET("/template/query/type", middleware.AuthMiddleware(), controller.GetTemplatesByType) // 任务管理路由 - router.POST("/task/add", middleware.AuthMiddleware(), controller.CreateTask) // 创建任务 - router.GET("/task/get", middleware.AuthMiddleware(), controller.GetTaskByID) // 获取任务信息 - router.PUT("/task/update", middleware.AuthMiddleware(), controller.UpdateTask) // 修改任务 - router.DELETE("/task/delete", middleware.AuthMiddleware(), controller.DeleteTask) // 删除任务 - router.GET("/task/list", middleware.AuthMiddleware(), controller.ListTasks) // 获取任务列表 + router.POST("/task/add", middleware.AuthMiddleware(), controller.CreateTask) // 创建任务 + router.GET("/task/get", middleware.AuthMiddleware(), controller.GetTaskByID) // 获取任务信息 + router.PUT("/task/update", middleware.AuthMiddleware(), controller.UpdateTask) // 修改任务 + router.DELETE("/task/delete", middleware.AuthMiddleware(), controller.DeleteTask) // 删除任务 + router.GET("/task/list", middleware.AuthMiddleware(), controller.ListTasks) // 获取任务列表 router.GET("/task/list-with-details", middleware.AuthMiddleware(), controller.ListTasksWithDetails) // 获取任务列表(包含关联信息) - router.GET("/task/query/name", middleware.AuthMiddleware(), controller.GetTasksByName) // 获取任务名称列表 - router.GET("/task/query/type", middleware.AuthMiddleware(), controller.GetTasksByType) // 获取任务类型列表 - router.GET("/task/query/status", middleware.AuthMiddleware(), controller.GetTasksByStatus) // 获取任务状态列表 - router.GET("/task/next-execution", middleware.AuthMiddleware(), controller.GetNextExecutionTime) // 获取任务下次执行时间 - router.GET("/task/execution-info", middleware.AuthMiddleware(), controller.GetTaskExecutionInfo) // 获取任务执行信息 - router.GET("/task/templates", middleware.AuthMiddleware(), controller.GetTaskTemplatesWithStatus) // 获取任务模板列表 + router.GET("/task/query/name", middleware.AuthMiddleware(), controller.GetTasksByName) // 获取任务名称列表 + router.GET("/task/query/type", middleware.AuthMiddleware(), controller.GetTasksByType) // 获取任务类型列表 + router.GET("/task/query/status", middleware.AuthMiddleware(), controller.GetTasksByStatus) // 获取任务状态列表 + router.GET("/task/next-execution", middleware.AuthMiddleware(), controller.GetNextExecutionTime) // 获取任务下次执行时间 + router.GET("/task/execution-info", middleware.AuthMiddleware(), controller.GetTaskExecutionInfo) // 获取任务执行信息 + router.GET("/task/templates", middleware.AuthMiddleware(), controller.GetTaskTemplatesWithStatus) // 获取任务模板列表 // 任务作业路由 router.POST("/taskjob/start", middleware.AuthMiddleware(), controller.TaskWork().StartJob) @@ -42,18 +42,18 @@ func RegisterTaskRoutes(router *gin.RouterGroup) { // 任务监控路由 taskMonitorCtrl := controller.NewTaskMonitorController() - router.GET("/task/monitor/queue/metrics", middleware.AuthMiddleware(), taskMonitorCtrl.GetQueueMetrics) // 获取队列指标 - router.GET("/task/monitor/scheduler/stats", middleware.AuthMiddleware(), taskMonitorCtrl.GetSchedulerStats) // 获取调度器统计 - router.GET("/task/monitor/system/status", middleware.AuthMiddleware(), taskMonitorCtrl.GetSystemStatus) // 获取系统状态 - router.GET("/task/monitor/queue/details", middleware.AuthMiddleware(), taskMonitorCtrl.GetQueueDetails) // 获取队列详情 + router.GET("/task/monitor/queue/metrics", middleware.AuthMiddleware(), taskMonitorCtrl.GetQueueMetrics) // 获取队列指标 + router.GET("/task/monitor/scheduler/stats", middleware.AuthMiddleware(), taskMonitorCtrl.GetSchedulerStats) // 获取调度器统计 + router.GET("/task/monitor/system/status", middleware.AuthMiddleware(), taskMonitorCtrl.GetSystemStatus) // 获取系统状态 + router.GET("/task/monitor/queue/details", middleware.AuthMiddleware(), taskMonitorCtrl.GetQueueDetails) // 获取队列详情 router.POST("/task/monitor/queue/clear-failed", middleware.AuthMiddleware(), taskMonitorCtrl.ClearFailedQueue) // 清空失败队列 router.POST("/task/monitor/queue/retry-failed", middleware.AuthMiddleware(), taskMonitorCtrl.RetryFailedTasks) // 重试失败任务 // 定时任务管理路由 - router.POST("/task/monitor/scheduled/pause", middleware.AuthMiddleware(), taskMonitorCtrl.PauseScheduledTask) // 暂停定时任务 - router.POST("/task/monitor/scheduled/resume", middleware.AuthMiddleware(), taskMonitorCtrl.ResumeScheduledTask) // 恢复定时任务 + router.POST("/task/monitor/scheduled/pause", middleware.AuthMiddleware(), taskMonitorCtrl.PauseScheduledTask) // 暂停定时任务 + router.POST("/task/monitor/scheduled/resume", middleware.AuthMiddleware(), taskMonitorCtrl.ResumeScheduledTask) // 恢复定时任务 router.POST("/task/monitor/scheduled/reset", middleware.AuthMiddleware(), taskMonitorCtrl.ResetScheduledTaskStatus) // 重置定时任务状态 - router.GET("/task/monitor/task/status", middleware.AuthMiddleware(), taskMonitorCtrl.GetTaskStatus) // 获取任务状态详情 + router.GET("/task/monitor/task/status", middleware.AuthMiddleware(), taskMonitorCtrl.GetTaskStatus) // 获取任务状态详情 // Ansible任务路由 taskAnsibleCtrl := controller.NewTaskAnsibleController(service.NewTaskAnsibleService(common.GetDB())) @@ -61,12 +61,29 @@ func RegisterTaskRoutes(router *gin.RouterGroup) { router.POST("/task/ansible", middleware.AuthMiddleware(), taskAnsibleCtrl.CreateTask) // 创建Ansible任务 router.POST("/task/k8s", middleware.AuthMiddleware(), taskAnsibleCtrl.CreateK8sTask) // 创建K8s任务 router.GET("/task/ansible/:id", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTask) // 通过任务ID获取任务信息 + router.PUT("/task/ansible/:id", middleware.AuthMiddleware(), taskAnsibleCtrl.UpdateTask) // 修改任务 router.POST("/task/ansible/:id/start", middleware.AuthMiddleware(), taskAnsibleCtrl.StartTask) // 启动任务(支持Ansible和K8s) router.DELETE("/task/ansible/:id", middleware.AuthMiddleware(), taskAnsibleCtrl.DeleteTask) // 删除任务(级联删除子任务) router.GET("/task/ansible/:id/log/:work_id", middleware.AuthMiddleware(), taskAnsibleCtrl.GetJobLog) // 获取任务日志(SSE) router.GET("/task/ansible/query/name", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTasksByName) // 根据名称模糊查询任务 + router.GET("/task/ansible/query", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTasks) // 多条件查询任务 router.GET("/task/ansible/query/type", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTasksByType) // 根据类型查询任务 + // 任务历史记录路由 + router.GET("/task/ansible/:id/history", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTaskHistoryList) // 获取任务历史列表 + router.GET("/task/ansible/:id/history/:history_id", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTaskHistoryDetail) // 获取任务历史详情(包含taskId校验) + router.DELETE("/task/ansible/:id/history/:history_id", middleware.AuthMiddleware(), taskAnsibleCtrl.DeleteTaskHistory) // 删除任务历史记录 + router.GET("/task/ansible/history/work/:work_history_id/log", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTaskHistoryLog) // 获取历史任务日志内容 + router.GET("/task/ansible/history/detail/task/:task_id/work/:work_id/history/:history_id/log", middleware.AuthMiddleware(), taskAnsibleCtrl.GetTaskHistoryLogByDetails) // 获取历史任务日志内容(通过详细信息) + + // Ansible配置管理路由 + configAnsibleCtrl := controller.NewConfigAnsibleController(service.NewConfigAnsibleService(common.GetDB())) + router.POST("/config/ansible", middleware.AuthMiddleware(), configAnsibleCtrl.Create) + router.PUT("/config/ansible/:id", middleware.AuthMiddleware(), configAnsibleCtrl.Update) + router.DELETE("/config/ansible/:id", middleware.AuthMiddleware(), configAnsibleCtrl.Delete) + router.GET("/config/ansible/:id", middleware.AuthMiddleware(), configAnsibleCtrl.Get) + router.GET("/config/ansible", middleware.AuthMiddleware(), configAnsibleCtrl.List) + } // RegisterWebSocketRoutes 注册WebSocket路由(不需要中间件认证) @@ -74,4 +91,4 @@ func RegisterWebSocketRoutes(router *gin.RouterGroup) { // WebSocket任务日志路由 (内部处理认证) wsCtrl := controller.NewWebSocketController(service.NewTaskAnsibleService(common.GetDB())) router.GET("/ws/task/ansible/:id/log/:work_id", wsCtrl.GetJobLogWS) // WebSocket日志接口 -} +} \ No newline at end of file diff --git a/web/.env.dev b/web/.env.dev new file mode 100644 index 00000000..bc65772c --- /dev/null +++ b/web/.env.dev @@ -0,0 +1 @@ +VUE_APP_API_BASE_URL=http://127.0.0.1:8000 \ No newline at end of file diff --git a/web/.env.prod b/web/.env.prod new file mode 100644 index 00000000..4d5def32 --- /dev/null +++ b/web/.env.prod @@ -0,0 +1 @@ +VUE_APP_API_BASE_URL=https://yourdomain.com:port \ No newline at end of file diff --git a/web/package.json b/web/package.json index bc097cf0..ffefa2f4 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "serve": "vue-cli-service serve --mode dev", - "build": "vue-cli-service build", + "build": "vue-cli-service build --mode prod", "lint": "vue-cli-service lint" }, "dependencies": { diff --git a/web/src/api/k8s.js b/web/src/api/k8s.js index 2904801f..3f8d7deb 100644 --- a/web/src/api/k8s.js +++ b/web/src/api/k8s.js @@ -1094,5 +1094,79 @@ export default { method: 'put', data: { yaml: yamlContent } }) + }, + + // ========================================== + // K8s CRD (自定义资源) 管理 + // ========================================== + + // 获取 CRD API Group 列表 + getCRDGroups(clusterId) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/crds/groups`, + method: 'get' + }) + }, + + // 获取系统中全部的CRD列表 + getCRDList(clusterId, params) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/crds`, + method: 'get', + params: params + }) + }, + + // 获取某个CRD下的自定义资源(CR)列表 + getCustomResourceList(clusterId, namespaceName, crdName, params) { + return request({ + // 假设crdName格式为 "prometheusrules.monitoring.coreos.com" + url: `/api/v1/k8s/cluster/${clusterId}/namespaces/${namespaceName}/crds/${crdName}/resources`, + method: 'get', + params: params + }) + }, + + // 获取指定的自定义资源详情 + getCustomResourceDetail(clusterId, namespaceName, crdName, crName) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/namespaces/${namespaceName}/crds/${crdName}/resources/${crName}`, + method: 'get' + }) + }, + + // 创建自定义资源 + createCustomResource(clusterId, namespaceName, crdName, data) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/namespaces/${namespaceName}/crds/${crdName}/resources`, + method: 'post', + data: data + }) + }, + + // 删除自定义资源 + deleteCustomResource(clusterId, namespaceName, crdName, crName) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/namespaces/${namespaceName}/crds/${crdName}/resources/${crName}`, + method: 'delete' + }) + }, + + // 获取自定义资源的 YAML + getCustomResourceYaml(clusterId, namespaceName, crdName, crName) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/namespaces/${namespaceName}/crds/${crdName}/resources/${crName}/yaml`, + method: 'get' + }) + }, + + // 更新自定义资源的 YAML + updateCustomResourceYaml(clusterId, namespaceName, crdName, crName, yamlContent) { + return request({ + url: `/api/v1/k8s/cluster/${clusterId}/namespaces/${namespaceName}/crds/${crdName}/resources/${crName}/yaml`, + method: 'put', + data: { yaml: yamlContent } + }) } + } \ No newline at end of file diff --git a/web/src/api/task.js b/web/src/api/task.js index 808ab883..6a1590b3 100644 --- a/web/src/api/task.js +++ b/web/src/api/task.js @@ -1,4 +1,5 @@ import request from '@/utils/request' +import storage from "@/utils/storage" export function CreateTemplate(data) { return request({ @@ -452,7 +453,7 @@ function getValidToken() { // 优先从localStorage获取 for (const key of storageKeys) { - token = localStorage.getItem(key) + token = storage.getItem(key) if (token && token !== 'null' && token !== 'undefined') { break } @@ -492,30 +493,68 @@ function getValidToken() { return token } -// SSE实时日志流接口 -export function GetAnsibleTaskLogStream(id, workId) { - const token = getValidToken() - // 使用当前页面的协议和主机,支持Docker部署 - const protocol = window.location.protocol - const host = window.location.host - const baseURL = `${protocol}//${host}` - const url = `${baseURL}/api/v1/task/ansible/${id}/log/${workId}` +// SSE实时日志流接口(回调式) +export function GetAnsibleTaskLogStream(id, workId, handlers = {}) { + const { onOpen, onMessage, onError, onClose } = handlers + const token = storage.getItem("token") || {} + const baseURL = (process.env.VUE_APP_API_BASE_URL || '').replace(/\/$/, '') + const url = `${baseURL}/api/v1/task/ansible/${id}/log/${workId}?t=${Date.now()}&realtime=true&includeBuffer=true` - if (!token) { - console.error('警告: 未找到有效的认证token') - } + const controller = new AbortController() + const decoder = new TextDecoder() - console.log('构造SSE URL:', { - hasValidToken: !!token, - tokenPreview: token ? `${token.substring(0, 10)}...` : 'null', - baseURL, - id, - workId, - finalUrl: `${url}?token=${encodeURIComponent(token || '')}` + fetch(url, { + method: 'GET', + headers: { + 'Authorization': token ? `Bearer ${token}` : '', + 'Accept': 'text/event-stream' + }, + signal: controller.signal }) + .then(async response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + onOpen && onOpen() + + const reader = response.body.getReader() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const events = buffer.split('\n\n') + buffer = events.pop() || '' + + events.forEach(eventBlock => { + eventBlock.split('\n').forEach(line => { + if (line.startsWith('data:')) { + const payload = line.slice(5).trim() + if (payload) { + onMessage && onMessage(payload) + } + } + }) + }) + } + + onClose && onClose() + }) + .catch(error => { + if (error.name !== 'AbortError') { + console.error('❌ 日志请求失败:', error) + onError && onError(error) + } + onClose && onClose() + }) return { - url: `${url}?token=${encodeURIComponent(token || '')}` + close() { + controller.abort() + } } } @@ -617,4 +656,4 @@ export function ResumeScheduledTask(taskId) { 'Accept': 'application/json' } }) -} +} \ No newline at end of file diff --git a/web/src/router/k8s.js b/web/src/router/k8s.js index 9421d1f5..f6019aa1 100644 --- a/web/src/router/k8s.js +++ b/web/src/router/k8s.js @@ -77,6 +77,12 @@ const routes = [ component: k8smonitoring, meta: {sTitle: '容器管理', tTitle: '监控仪表板'} }, + { + path: '/k8s/crd', + name: 'K8sCrd', + component: () => import('@/views/K8s/k8s-crd.vue'), + meta: { title: '自定义资源', icon: 'coin' } + } ] export default routes diff --git a/web/src/utils/request.js b/web/src/utils/request.js index 9ad97f14..dc9e6920 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -11,7 +11,7 @@ import storage from "./storage" // 创建axios对象,添加全局配置 const service = axios.create({ - baseURL: process.env.NODE_ENV === 'development' ? '' : '', // 开发和生产环境都使用相对路径 + baseURL: (process.env.VUE_APP_API_BASE_URL || '').replace(/\/$/, ''), // 通过环境变量指定后端地址 timeout: 15000, // 增加到15秒 headers: { 'Content-Type': 'application/json' @@ -51,6 +51,8 @@ service.interceptors.request.use((req) => { return Promise.reject(new Error('正在跳转登录页')) } + console.log('API Base URL:', process.env.VUE_APP_API_BASE_URL) + const headers = req.headers const token = storage.getItem("token") || {} if(!headers.Authorization) { @@ -129,4 +131,4 @@ function request(options) { return service(options) } -export default request +export default request \ No newline at end of file diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index c7aeb982..9fe61ddb 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -105,6 +105,20 @@ export default { // 确保数据是数组格式 if (Array.isArray(menuData)) { this.leftMenuList = menuData; + + // 容器管理 + const k8sMenu = this.leftMenuList.find(item => item.menuName === '容器管理') + if (k8sMenu && k8sMenu.menuSvoList) { + const crdExists = k8sMenu.menuSvoList.some(sub => sub.url === 'k8s/crd') + if (!crdExists) { + k8sMenu.menuSvoList.push({ + id: 99999, + menuName: 'CRD管理', + url: 'k8s/crd', + icon: 'Setting' + }) + } + } // 手动添加配置管理菜单到任务中心 const taskMenu = this.leftMenuList.find(item => item.menuName === '任务中心') diff --git a/web/src/views/K8s/k8s-crd.vue b/web/src/views/K8s/k8s-crd.vue new file mode 100644 index 00000000..8ad11013 --- /dev/null +++ b/web/src/views/K8s/k8s-crd.vue @@ -0,0 +1,824 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/task/Job/AnsibleLogDialog.vue b/web/src/views/task/Job/AnsibleLogDialog.vue index 66a61948..b89e838f 100644 --- a/web/src/views/task/Job/AnsibleLogDialog.vue +++ b/web/src/views/task/Job/AnsibleLogDialog.vue @@ -87,10 +87,11 @@