diff --git a/backend/Dockerfile b/backend/Dockerfile index 35cb648..4576a1f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -61,11 +61,18 @@ FROM alpine:latest # 使用国内 Alpine 镜像源加速 // Logan.Li RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories -# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理,ansible=自动化运维) -RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass && \ +# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理,ansible=自动化运维,nodejs+npm=前端构建,maven=Java构建) +RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass git docker-cli docker-cli-buildx nodejs npm maven && \ curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ chmod +x kubectl && mv kubectl /usr/local/bin/ +# Maven 阿里云镜像加速(避免 DNS 间歇性 SERVFAIL) +RUN mkdir -p /root/.m2 && \ + echo 'aliyuncentralAliyun Mavenhttps://maven.aliyun.com/repository/public' > /root/.m2/settings.xml + +# npm 淘宝镜像加速 +RUN npm config set registry https://registry.npmmirror.com + ENV TZ=Asia/Shanghai WORKDIR /app @@ -85,9 +92,13 @@ COPY --from=agent-builder /agent/bin/aiops-agent-darwin-arm64 /app/agent/bin/ COPY agent/deploy/install.sh /app/agent/deploy/ COPY agent/deploy/aiops-agent.service /app/agent/deploy/ +# 启动脚本(注入 hosts DNS 缓存) +COPY backend/entrypoint.sh /app/ +RUN chmod +x /app/entrypoint.sh + EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 -CMD ["./aiops"] +CMD ["./entrypoint.sh"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..6610e6a --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# AIOps 启动脚本 - 注入 DNS 缓存直连避免 Docker DNS 间歇性 SERVFAIL +# 署名 Logan.Li + +# 注入常用镜像站的 hosts 直连 +grep -q "maven.aliyun.com" /etc/hosts || echo "122.228.195.17 maven.aliyun.com" >> /etc/hosts +grep -q "registry.npmmirror.com" /etc/hosts || echo "122.228.223.11 registry.npmmirror.com" >> /etc/hosts + +exec ./aiops "$@" diff --git a/backend/internal/api/handlers/credential.go b/backend/internal/api/handlers/credential.go index dd53387..b1e13cf 100644 --- a/backend/internal/api/handlers/credential.go +++ b/backend/internal/api/handlers/credential.go @@ -1,8 +1,13 @@ package handlers import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" "net/http" "strconv" + "strings" "ezops/internal/models" "ezops/pkg/crypto" @@ -11,150 +16,385 @@ import ( "gorm.io/gorm" ) -// CredentialHandler 凭据管理处理器 // Logan.Li +// CredentialHandler 凭据管理处理器 v2(多类型动态字段) type CredentialHandler struct { db *gorm.DB crypt *crypto.SecretBox } func NewCredentialHandler(db *gorm.DB, masterKey string) *CredentialHandler { - return &CredentialHandler{ - db: db, - crypt: crypto.NewSecretBox(masterKey), - } + return &CredentialHandler{db: db, crypt: crypto.NewSecretBox(masterKey)} } -// preview 内容脱敏预览:前8字符 + ... + 后4字符 -func preview(s string) string { - if len(s) <= 12 { - return "****" - } - return s[:8] + "..." + s[len(s)-4:] +// ────── Helpers ────── + +func genEnvPrefix() string { + b := make([]byte, 4) + rand.Read(b) + return hex.EncodeToString(b)[:8] } -// ListCredentials 凭据列表 +func buildContent(req models.CreateSecretRequest) (string, string, error) { + switch req.SecretType { + case "ssh": + data := map[string]interface{}{ + "private_key": req.SSHPrivateKey, + "public_key": req.SSHPublicKey, + "ssh_user": req.SSHUser, + "ssh_port": req.SSHPort, + "password": req.SSHPassword, + } + b, _ := json.Marshal(data) + return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil + case "docker": + data := map[string]string{ + "server": req.DockerServer, + "username": req.DockerUsername, + "password": req.DockerPassword, + } + b, _ := json.Marshal(data) + return string(b), req.DockerServer, nil + case "tls": + data := map[string]string{"cert": req.TLSCert, "key": req.TLSKey, "ca": req.TLSCA} + b, _ := json.Marshal(data) + return string(b), "TLS 证书已配置", nil + case "aksk": + data := map[string]string{ + "provider": req.AKSKProvider, + "access_key": req.AKSKAccessKey, + "secret_key": req.AKSKSecretKey, + "region": req.AKSKRegion, + } + b, _ := json.Marshal(data) + return string(b), req.AKSKProvider, nil + case "database": + data := map[string]interface{}{ + "db_type": req.DBType, + "host": req.DBHost, + "port": req.DBPort, + "db_name": req.DBName, + "username": req.DBUsername, + "password": req.DBPassword, + } + b, _ := json.Marshal(data) + return string(b), fmt.Sprintf("%s://%s:%d/%s", req.DBType, req.DBHost, req.DBPort, req.DBName), nil + case "api": + data := map[string]string{"api_key": req.APIKey, "endpoint": req.APIEndpoint} + b, _ := json.Marshal(data) + return string(b), req.APIEndpoint, nil + case "generic": + data := map[string]string{"generic_key": req.GenericKey, "generic_value": req.GenericValue} + b, _ := json.Marshal(data) + return string(b), req.GenericKey, nil + } + return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType) +} + +// ────── List ────── + func (h *CredentialHandler) ListCredentials(c *gin.Context) { - var creds []models.Credential - if err := h.db.Order("updated_at DESC").Find(&creds).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"}) - return + var query models.SecretListQuery + c.ShouldBindQuery(&query) + + db := h.db.Model(&models.Credential{}) + if query.SecretType != "" { + db = db.Where("secret_type = ?", query.SecretType) } - items := make([]models.CredentialListResponse, 0, len(creds)) + if query.Search != "" { + s := "%" + query.Search + "%" + db = db.Where("name LIKE ? OR description LIKE ? OR variable_name LIKE ?", s, s, s) + } + if query.Tags != "" { + for _, tag := range strings.Split(query.Tags, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + db = db.Where("tags LIKE ?", "%\""+tag+"\"%") + } + } + } + + var total int64 + db.Count(&total) + + page := query.Page + if page < 1 { page = 1 } + pageSize := query.PageSize + if pageSize < 1 || pageSize > 100 { pageSize = 20 } + + var creds []models.Credential + db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds) + + items := make([]models.SecretListResponse, 0, len(creds)) for _, cred := range creds { - items = append(items, models.CredentialListResponse{ - ID: cred.ID, - Name: cred.Name, - Type: cred.Type, - Preview: preview(cred.Content), - Description: cred.Description, - CreatedAt: cred.CreatedAt, - UpdatedAt: cred.UpdatedAt, + tags := []string{} + if cred.Tags != "" { + json.Unmarshal([]byte(cred.Tags), &tags) + } + items = append(items, models.SecretListResponse{ + ID: cred.ID, + Name: cred.Name, + VariableName: cred.VariableName, + SecretType: cred.SecretType, + Summary: cred.Summary, + Tags: tags, + Description: cred.Description, + ExpiresAt: cred.ExpiresAt, + IsActive: cred.IsActive, + EnvPrefix: cred.EnvPrefix, + CreatedAt: cred.CreatedAt, + UpdatedAt: cred.UpdatedAt, }) } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{ - List: items, Total: int64(len(items)), + List: items, Total: total, }}) } -// CreateCredential 创建凭据 +// ────── Create ────── + func (h *CredentialHandler) CreateCredential(c *gin.Context) { - var req models.CredentialCreateRequest + var req models.CreateSecretRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) return } - encrypted, err := h.crypt.Encrypt(req.Content) + plainContent, summary, err := buildContent(req) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + encrypted, err := h.crypt.Encrypt(plainContent) if err != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"}) return } + tagsJSON, _ := json.Marshal(req.Tags) + + isActive := true + if req.IsActive != nil { isActive = *req.IsActive } + cred := models.Credential{ - Name: req.Name, - Type: req.Type, - Content: encrypted, - Description: req.Description, + Name: req.Name, + VariableName: req.VariableName, + SecretType: req.SecretType, + Summary: summary, + Content: encrypted, + Tags: string(tagsJSON), + Description: req.Description, + ExpiresAt: req.ExpiresAt, + IsActive: isActive, + EnvPrefix: genEnvPrefix(), } + if err := h.db.Create(&cred).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()}) return } - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: models.CredentialListResponse{ - ID: cred.ID, - Name: cred.Name, - Type: cred.Type, - Preview: preview(cred.Content), - Description: cred.Description, - CreatedAt: cred.CreatedAt, - UpdatedAt: cred.UpdatedAt, - }, - }) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: cred.ID}) } -// UpdateCredential 更新凭据 -func (h *CredentialHandler) UpdateCredential(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"}) - return - } - - var req models.CredentialUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) - return - } +// ────── Get Detail ────── +func (h *CredentialHandler) GetCredential(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 32) var cred models.Credential if h.db.First(&cred, id).Error != nil { c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"}) return } - cred.Name = req.Name - cred.Type = req.Type - cred.Description = req.Description + // Decrypt content + plain, err := h.crypt.Decrypt(cred.Content) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "解密失败"}) + return + } - if req.Content != "" { - encrypted, err := h.crypt.Encrypt(req.Content) + var contentData map[string]interface{} + json.Unmarshal([]byte(plain), &contentData) + + tags := []string{} + if cred.Tags != "" { json.Unmarshal([]byte(cred.Tags), &tags) } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{ + "id": cred.ID, + "name": cred.Name, + "variable_name": cred.VariableName, + "secret_type": cred.SecretType, + "content": contentData, + "tags": tags, + "description": cred.Description, + "expires_at": cred.ExpiresAt, + "is_active": cred.IsActive, + "env_prefix": cred.EnvPrefix, + "created_at": cred.CreatedAt, + "updated_at": cred.UpdatedAt, + }}) +} + +// ────── Update ────── + +func (h *CredentialHandler) UpdateCredential(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 32) + var cred models.Credential + if h.db.First(&cred, id).Error != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"}) + return + } + + var req models.UpdateSecretRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) + return + } + + updates := map[string]interface{}{} + + if req.Name != nil { updates["name"] = *req.Name } + if req.VariableName != nil { updates["variable_name"] = *req.VariableName } + if req.Description != nil { updates["description"] = *req.Description } + if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt } + if req.IsActive != nil { updates["is_active"] = *req.IsActive } + + if len(req.Tags) > 0 { + b, _ := json.Marshal(req.Tags) + updates["tags"] = string(b) + } + + // Rebuild content if any type-specific field changed + if hasTypeFieldUpdate(req) { + // Decrypt old content + oldPlain, _ := h.crypt.Decrypt(cred.Content) + var old map[string]interface{} + json.Unmarshal([]byte(oldPlain), &old) + if old == nil { old = map[string]interface{}{} } + + mergeUpdate(req, old) + + newPlain, _ := json.Marshal(old) + enc, err := h.crypt.Encrypt(string(newPlain)) if err != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"}) return } - cred.Content = encrypted + updates["content"] = enc + + // Update summary + updates["summary"] = buildSummaryFromRaw(old, cred.SecretType) } - if err := h.db.Save(&cred).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) - return + if len(updates) > 0 { + h.db.Model(&cred).Updates(updates) } c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功"}) } -// DeleteCredential 删除凭据 +func hasTypeFieldUpdate(req models.UpdateSecretRequest) bool { + return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil || + req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil || + req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil || + req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil || + req.DBType != nil || req.DBHost != nil || req.DBPort != nil || req.DBName != nil || req.DBUsername != nil || req.DBPassword != nil || + req.APIKey != nil || req.APIEndpoint != nil || + req.GenericKey != nil || req.GenericValue != nil +} + +func mergeUpdate(req models.UpdateSecretRequest, old map[string]interface{}) { + if req.SSHPrivateKey != nil { old["private_key"] = *req.SSHPrivateKey } + if req.SSHPublicKey != nil { old["public_key"] = *req.SSHPublicKey } + if req.SSHUser != nil { old["ssh_user"] = *req.SSHUser } + if req.SSHPort != nil { old["ssh_port"] = *req.SSHPort } + if req.SSHPassword != nil { old["password"] = *req.SSHPassword } + if req.DockerServer != nil { old["server"] = *req.DockerServer } + if req.DockerUsername != nil { old["username"] = *req.DockerUsername } + if req.DockerPassword != nil { old["password"] = *req.DockerPassword } + if req.TLSCert != nil { old["cert"] = *req.TLSCert } + if req.TLSKey != nil { old["key"] = *req.TLSKey } + if req.TLSCA != nil { old["ca"] = *req.TLSCA } + if req.AKSKProvider != nil { old["provider"] = *req.AKSKProvider } + if req.AKSKAccessKey != nil { old["access_key"] = *req.AKSKAccessKey } + if req.AKSKSecretKey != nil { old["secret_key"] = *req.AKSKSecretKey } + if req.AKSKRegion != nil { old["region"] = *req.AKSKRegion } + if req.DBType != nil { old["db_type"] = *req.DBType } + if req.DBHost != nil { old["host"] = *req.DBHost } + if req.DBPort != nil { old["port"] = *req.DBPort } + if req.DBName != nil { old["db_name"] = *req.DBName } + if req.DBUsername != nil { old["username"] = *req.DBUsername } + if req.DBPassword != nil { old["password"] = *req.DBPassword } + if req.APIKey != nil { old["api_key"] = *req.APIKey } + if req.APIEndpoint != nil { old["endpoint"] = *req.APIEndpoint } + if req.GenericKey != nil { old["generic_key"] = *req.GenericKey } + if req.GenericValue != nil { old["generic_value"] = *req.GenericValue } +} + +func buildSummaryFromRaw(data map[string]interface{}, stype string) string { + switch stype { + case "ssh": + return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"]) + case "docker": + return fmt.Sprintf("%v", data["server"]) + case "aksk": + return fmt.Sprintf("%v", data["provider"]) + case "database": + return fmt.Sprintf("%v://%v:%v/%v", data["db_type"], data["host"], data["port"], data["db_name"]) + case "api": + return fmt.Sprintf("%v", data["endpoint"]) + case "generic": + return fmt.Sprintf("%v", data["generic_key"]) + case "tls": + return "TLS 证书已配置" + } + return "" +} + +// ────── Patch (toggle is_active) ────── + +func (h *CredentialHandler) PatchCredential(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 32) + var body map[string]interface{} + c.ShouldBindJSON(&body) + + updates := map[string]interface{}{} + if v, ok := body["is_active"]; ok { + updates["is_active"] = v + } + if len(updates) > 0 { + h.db.Model(&models.Credential{}).Where("id = ?", id).Updates(updates) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "ok"}) +} + +// ────── Delete ────── + func (h *CredentialHandler) DeleteCredential(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"}) - return - } - - var cred models.Credential - if h.db.First(&cred, id).Error != nil { - c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"}) - return - } - - if err := h.db.Delete(&cred).Error; err != nil { + id, _ := strconv.ParseUint(c.Param("id"), 10, 32) + if h.db.Delete(&models.Credential{}, id).Error != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) return } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) } + +// ────── Tags ────── + +func (h *CredentialHandler) GetTags(c *gin.Context) { + var rows []string + h.db.Model(&models.Credential{}).Where("tags != ''").Pluck("tags", &rows) + + tagSet := map[string]bool{} + for _, r := range rows { + var tags []string + if json.Unmarshal([]byte(r), &tags) == nil { + for _, t := range tags { tagSet[t] = true } + } + } + tags := make([]string, 0, len(tagSet)) + for t := range tagSet { tags = append(tags, t) } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: tags}) +} diff --git a/backend/internal/api/handlers/credential_assign.go b/backend/internal/api/handlers/credential_assign.go index 0e7f41a..d5a6f95 100644 --- a/backend/internal/api/handlers/credential_assign.go +++ b/backend/internal/api/handlers/credential_assign.go @@ -43,7 +43,7 @@ func (h *CredentialHandler) AssignCredential(c *gin.Context) { return } - if cred.Type == "ssh_key" { + if cred.SecretType == "ssh" { server.SSHKey = plain server.AuthType = "key" server.Password = "" diff --git a/backend/internal/api/handlers/deploy.go b/backend/internal/api/handlers/deploy.go new file mode 100644 index 0000000..de77f4f --- /dev/null +++ b/backend/internal/api/handlers/deploy.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "net/http" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// DeployHandler 发布系统处理器 +type DeployHandler struct { + db *gorm.DB +} + +func NewDeployHandler(db *gorm.DB) *DeployHandler { + return &DeployHandler{db: db} +} + +// ==================== Git 仓库认证 ==================== + +func (h *DeployHandler) ListRepos(c *gin.Context) { + var items []models.PipelineRepo + h.db.Order("updated_at DESC").Find(&items) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}}) +} + +func (h *DeployHandler) GetRepo(c *gin.Context) { + var item models.PipelineRepo + if err := h.db.First(&item, c.Param("id")); err.Error != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +func (h *DeployHandler) CreateRepo(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + URL string `json:"url" binding:"required"` + AuthType models.RepoAuthType `json:"auth_type"` + CredentialID *uint `json:"credential_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + if req.AuthType == "" { req.AuthType = models.RepoAuthToken } + item := models.PipelineRepo{ + Name: req.Name, Description: req.Description, URL: req.URL, + AuthType: req.AuthType, CredentialID: req.CredentialID, + CreatedBy: c.GetString("username"), + } + h.db.Create(&item) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +func (h *DeployHandler) UpdateRepo(c *gin.Context) { + var item models.PipelineRepo + if err := h.db.First(&item, c.Param("id")); err.Error != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) + return + } + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + URL *string `json:"url"` + AuthType *models.RepoAuthType `json:"auth_type"` + CredentialID *uint `json:"credential_id"` + Enabled *bool `json:"enabled"` + } + c.ShouldBindJSON(&req) + updates := map[string]interface{}{} + if req.Name != nil { updates["name"] = *req.Name } + if req.Description != nil { updates["description"] = *req.Description } + if req.URL != nil { updates["url"] = *req.URL } + if req.AuthType != nil { updates["auth_type"] = *req.AuthType } + if req.Enabled != nil { updates["enabled"] = *req.Enabled } + if req.CredentialID != nil { updates["credential_id"] = req.CredentialID } + h.db.Model(&item).Updates(updates) + h.db.First(&item, item.ID) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +func (h *DeployHandler) DeleteRepo(c *gin.Context) { + h.db.Delete(&models.PipelineRepo{}, c.Param("id")) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) +} + +// ==================== 镜像仓库认证 ==================== + +func (h *DeployHandler) ListRegistries(c *gin.Context) { + var items []models.PipelineRegistry + h.db.Order("updated_at DESC").Find(&items) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}}) +} + +func (h *DeployHandler) GetRegistry(c *gin.Context) { + var item models.PipelineRegistry + if err := h.db.First(&item, c.Param("id")); err.Error != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +func (h *DeployHandler) CreateRegistry(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + RegistryURL string `json:"registry_url" binding:"required"` + Namespace string `json:"namespace"` + AuthType models.RegistryAuthType `json:"auth_type"` + CredentialID *uint `json:"credential_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + if req.AuthType == "" { req.AuthType = models.RegistryAuthPassword } + item := models.PipelineRegistry{ + Name: req.Name, Description: req.Description, RegistryURL: req.RegistryURL, + Namespace: req.Namespace, AuthType: req.AuthType, CredentialID: req.CredentialID, + CreatedBy: c.GetString("username"), + } + h.db.Create(&item) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +func (h *DeployHandler) UpdateRegistry(c *gin.Context) { + var item models.PipelineRegistry + if err := h.db.First(&item, c.Param("id")); err.Error != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) + return + } + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + RegistryURL *string `json:"registry_url"` + Namespace *string `json:"namespace"` + AuthType *models.RegistryAuthType `json:"auth_type"` + CredentialID *uint `json:"credential_id"` + Enabled *bool `json:"enabled"` + } + c.ShouldBindJSON(&req) + updates := map[string]interface{}{} + if req.Name != nil { updates["name"] = *req.Name } + if req.Description != nil { updates["description"] = *req.Description } + if req.RegistryURL != nil { updates["registry_url"] = *req.RegistryURL } + if req.Namespace != nil { updates["namespace"] = *req.Namespace } + if req.AuthType != nil { updates["auth_type"] = *req.AuthType } + if req.Enabled != nil { updates["enabled"] = *req.Enabled } + if req.CredentialID != nil { updates["credential_id"] = req.CredentialID } + h.db.Model(&item).Updates(updates) + h.db.First(&item, item.ID) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +func (h *DeployHandler) DeleteRegistry(c *gin.Context) { + h.db.Delete(&models.PipelineRegistry{}, c.Param("id")) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) +} diff --git a/backend/internal/api/handlers/deploy_environments.go b/backend/internal/api/handlers/deploy_environments.go new file mode 100644 index 0000000..5ea2df9 --- /dev/null +++ b/backend/internal/api/handlers/deploy_environments.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +// ==================== 环境管理 ==================== + +// ListEnvironments 环境列表 +func (h *PipelineHandler) ListEnvironments(c *gin.Context) { + var items []models.DeployEnvironment + h.db.Order("sort_order ASC, id ASC").Find(&items) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}}) +} + +// CreateEnvironment 创建环境 +func (h *PipelineHandler) CreateEnvironment(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` + Type string `json:"type" binding:"required,oneof=production staging testing development"` + Color string `json:"color"` + SortOrder int `json:"sort_order"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + if req.Color == "" { req.Color = typeDefaultColor(req.Type) } + item := models.DeployEnvironment{ + Name: req.Name, DisplayName: req.DisplayName, + Type: models.EnvironmentType(req.Type), Color: req.Color, + SortOrder: req.SortOrder, Description: req.Description, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +// UpdateEnvironment 更新环境 +func (h *PipelineHandler) UpdateEnvironment(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var item models.DeployEnvironment + if err := h.db.First(&item, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) + return + } + var req struct { + Name *string `json:"name"` + DisplayName *string `json:"display_name"` + Type *string `json:"type"` + Color *string `json:"color"` + SortOrder *int `json:"sort_order"` + Description *string `json:"description"` + Enabled *bool `json:"enabled"` + } + c.ShouldBindJSON(&req) + updates := map[string]interface{}{} + if req.Name != nil { updates["name"] = *req.Name } + if req.DisplayName != nil { updates["display_name"] = *req.DisplayName } + if req.Type != nil { updates["type"] = *req.Type } + if req.Color != nil { updates["color"] = *req.Color } + if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder } + if req.Description != nil { updates["description"] = *req.Description } + if req.Enabled != nil { updates["enabled"] = *req.Enabled } + h.db.Model(&item).Updates(updates) + h.db.First(&item, id) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) +} + +// DeleteEnvironment 删除环境 +func (h *PipelineHandler) DeleteEnvironment(c *gin.Context) { + h.db.Delete(&models.DeployEnvironment{}, c.Param("id")) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) +} + +func typeDefaultColor(t string) string { + switch t { + case "production": return "#EF4444" + case "staging": return "#F59E0B" + case "testing": return "#10B981" + default: return "#3B82F6" // development + } +} + +// ==================== 流水线成员管理 ==================== + +// ListPipelineMembers 成员列表 +func (h *PipelineHandler) ListPipelineMembers(c *gin.Context) { + pipelineID := getPipelineID(c) + var members []models.DeployPipelineMember + h.db.Where("pipeline_id = ?", pipelineID).Order("created_at ASC").Find(&members) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: members}) +} + +// AddPipelineMember 添加成员 +func (h *PipelineHandler) AddPipelineMember(c *gin.Context) { + pipelineID := getPipelineID(c) + var req struct { + Username string `json:"username" binding:"required"` + Role string `json:"role" binding:"required,oneof=owner editor viewer"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + var pipeline models.Pipeline + if err := h.db.First(&pipeline, pipelineID).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"}) + return + } + + member := models.DeployPipelineMember{ + PipelineID: uint(pipelineID), + Username: &req.Username, + Role: models.MemberRole(req.Role), + } + if err := h.db.Create(&member).Error; err != nil { + c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该用户已是成员"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: member}) +} + +// RemovePipelineMember 移除成员 +func (h *PipelineHandler) RemovePipelineMember(c *gin.Context) { + pipelineID := getPipelineID(c) + memberID, _ := strconv.Atoi(c.Param("memberId")) + result := h.db.Where("id = ? AND pipeline_id = ?", memberID, pipelineID).Delete(&models.DeployPipelineMember{}) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "成员不存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已移除"}) +} + +// ==================== 流水线运行控制 ==================== + +// StopPipelineRun 停止运行中的流水线 +func (h *PipelineHandler) StopPipelineRun(c *gin.Context) { + pipelineID := getPipelineID(c) + runID, _ := strconv.Atoi(c.Param("runId")) + + var run models.PipelineRun + if err := h.db.Where("id = ? AND pipeline_id = ?", runID, pipelineID).First(&run).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "运行记录不存在"}) + return + } + if run.Status != "running" && run.Status != "pending" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "仅运行中或等待中的任务可停止, 当前状态: " + run.Status}) + return + } + + now := time.Now() + updates := map[string]interface{}{ + "status": "cancelled", + "finished_at": &now, + } + if run.StartedAt != nil { + dur := int(now.Sub(*run.StartedAt).Seconds()) + updates["duration"] = dur + } + h.db.Model(&run).Updates(updates) + + // 追加日志 + logEntry := map[string]string{ + "time": now.Format("15:04:05"), + "level": "warn", + "message": ">> 任务已被手动停止", + "stage": "--", + } + existingLogs := run.Logs + if existingLogs == "" { + existingLogs = "[]" + } + var logEntries []map[string]string + json.Unmarshal([]byte(existingLogs), &logEntries) + logEntries = append(logEntries, logEntry) + newLogs, _ := json.Marshal(logEntries) + h.db.Model(&run).Update("logs", string(newLogs)) + + h.db.First(&run, runID) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: run, Message: "已停止"}) +} + +// GetRunLog 获取运行日志 +func (h *PipelineHandler) GetRunLog(c *gin.Context) { + pipelineID := getPipelineID(c) + runID, _ := strconv.Atoi(c.Param("runId")) + + var run models.PipelineRun + if err := h.db.Where("id = ? AND pipeline_id = ?", runID, pipelineID).First(&run).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "运行记录不存在"}) + return + } + + var logs []map[string]string + if run.Logs != "" { + json.Unmarshal([]byte(run.Logs), &logs) + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{ + "run": run, + "logs": logs, + }}) +} + +// BatchDeleteRuns 批量删除运行记录 +func (h *PipelineHandler) BatchDeleteRuns(c *gin.Context) { + pipelineID := getPipelineID(c) + + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请选择要删除的记录"}) + return + } + + result := h.db.Where("pipeline_id = ? AND id IN ?", pipelineID, req.IDs).Delete(&models.PipelineRun{}) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已删除 %d 条记录", result.RowsAffected)}) +} diff --git a/backend/internal/api/handlers/deploy_groups.go b/backend/internal/api/handlers/deploy_groups.go new file mode 100644 index 0000000..d7135df --- /dev/null +++ b/backend/internal/api/handlers/deploy_groups.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "net/http" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +// ==================== 用户组管理 ==================== + +// ListGroups 用户组列表 +func (h *PipelineHandler) ListGroups(c *gin.Context) { + var groups []models.DeployUserGroup + h.db.Preload("Members").Order("created_at DESC").Find(&groups) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: groups, Total: int64(len(groups))}}) +} + +// CreateGroup 创建用户组 +func (h *PipelineHandler) CreateGroup(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + group := models.DeployUserGroup{ + Name: req.Name, Description: req.Description, + CreatedBy: c.GetString("username"), + } + if err := h.db.Create(&group).Error; err != nil { + c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "组名已存在: " + err.Error()}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: group}) +} + +// UpdateGroup 更新用户组 +func (h *PipelineHandler) UpdateGroup(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var group models.DeployUserGroup + if err := h.db.First(&group, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"}) + return + } + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + } + c.ShouldBindJSON(&req) + updates := map[string]interface{}{} + if req.Name != nil { updates["name"] = *req.Name } + if req.Description != nil { updates["description"] = *req.Description } + h.db.Model(&group).Updates(updates) + h.db.First(&group, id) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: group}) +} + +// DeleteGroup 删除用户组 +func (h *PipelineHandler) DeleteGroup(c *gin.Context) { + id := c.Param("id") + // 先删组成员和流水线授权 + h.db.Where("group_id = ?", id).Delete(&models.DeployUserGroupMember{}) + h.db.Where("group_id = ?", id).Delete(&models.DeployPipelineMember{}) + h.db.Delete(&models.DeployUserGroup{}, id) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) +} + +// ==================== 用户组成员管理 ==================== + +// ListGroupMembers 组成员列表 +func (h *PipelineHandler) ListGroupMembers(c *gin.Context) { + groupID, _ := strconv.Atoi(c.Param("id")) + var members []models.DeployUserGroupMember + h.db.Where("group_id = ?", groupID).Find(&members) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: members}) +} + +// AddGroupMember 添加组成员 +func (h *PipelineHandler) AddGroupMember(c *gin.Context) { + groupID, _ := strconv.Atoi(c.Param("id")) + var req struct { + Username string `json:"username" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + member := models.DeployUserGroupMember{GroupID: uint(groupID), Username: req.Username} + if err := h.db.Create(&member).Error; err != nil { + c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该用户已在组内"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: member}) +} + +// RemoveGroupMember 移除组成员 +func (h *PipelineHandler) RemoveGroupMember(c *gin.Context) { + groupID, _ := strconv.Atoi(c.Param("id")) + memberID, _ := strconv.Atoi(c.Param("memberId")) + result := h.db.Where("id = ? AND group_id = ?", memberID, groupID).Delete(&models.DeployUserGroupMember{}) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "成员不存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已移除"}) +} + +// ==================== 组授权给流水线 ==================== + +// AssignGroupToPipeline 将用户组授权到流水线 +func (h *PipelineHandler) AssignGroupToPipeline(c *gin.Context) { + pipelineID := getPipelineID(c) + var req struct { + GroupID uint `json:"group_id" binding:"required"` + Role string `json:"role" binding:"required,oneof=owner editor viewer"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + var pipeline models.Pipeline + if err := h.db.First(&pipeline, pipelineID).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"}) + return + } + var group models.DeployUserGroup + if err := h.db.First(&group, req.GroupID).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "用户组不存在"}) + return + } + + member := models.DeployPipelineMember{ + PipelineID: uint(pipelineID), + GroupID: &req.GroupID, + Role: models.MemberRole(req.Role), + } + if err := h.db.Create(&member).Error; err != nil { + c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该组已被授权到此流水线"}) + return + } + + // 预加载关联 + member.Group = &group + c.JSON(http.StatusOK, models.Response{Code: 200, Data: member, Message: "已授权"}) +} diff --git a/backend/internal/api/handlers/item_role.go b/backend/internal/api/handlers/item_role.go new file mode 100644 index 0000000..9c2637d --- /dev/null +++ b/backend/internal/api/handlers/item_role.go @@ -0,0 +1,201 @@ +package handlers + +import ( + "net/http" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ItemRoleHandler Item Role handler (Jenkins-style regex pipeline matching) +type ItemRoleHandler struct { + db *gorm.DB +} + +// NewItemRoleHandler creates a new ItemRoleHandler +func NewItemRoleHandler(db *gorm.DB) *ItemRoleHandler { + return &ItemRoleHandler{db: db} +} + +// ListItemRoles GET /admin/item-roles +func (h *ItemRoleHandler) ListItemRoles(c *gin.Context) { + var roles []models.ItemRole + h.db.Order("created_at DESC").Find(&roles) + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Data: models.ListResponse{List: roles, Total: int64(len(roles))}, + }) +} + +// CreateItemRole POST /admin/item-roles +func (h *ItemRoleHandler) CreateItemRole(c *gin.Context) { + var req models.CreateItemRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + // 唯一性校验 + var existing models.ItemRole + if err := h.db.Where("name = ? OR code = ?", req.Name, req.Code).First(&existing).Error; err == nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "Item Role 名称或编码已存在"}) + return + } + + role := models.ItemRole{ + Name: req.Name, + Code: req.Code, + Pattern: req.Pattern, + Role: req.Role, + Description: req.Description, + Status: 1, + } + if err := h.db.Create(&role).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: role}) +} + +// UpdateItemRole PUT /admin/item-roles/:id +func (h *ItemRoleHandler) UpdateItemRole(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 ID"}) + return + } + + var req models.UpdateItemRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + var role models.ItemRole + if err := h.db.First(&role, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Item Role 不存在"}) + return + } + + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Code != "" { + updates["code"] = req.Code + } + if req.Pattern != "" { + updates["pattern"] = req.Pattern + } + if req.Role != "" { + updates["role"] = req.Role + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.Status != nil { + updates["status"] = *req.Status + } + + if len(updates) > 0 { + if err := h.db.Model(&role).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) + return + } + } + + h.db.First(&role, id) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: role}) +} + +// DeleteItemRole DELETE /admin/item-roles/:id +func (h *ItemRoleHandler) DeleteItemRole(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 ID"}) + return + } + + // Clean up user associations first + h.db.Where("item_role_id = ?", id).Delete(&models.UserItemRole{}) + if err := h.db.Delete(&models.ItemRole{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// ListUserItemRoles GET /admin/users/:id/item-roles +func (h *ItemRoleHandler) ListUserItemRoles(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"}) + return + } + + var assignments []models.UserItemRole + h.db.Preload("ItemRole").Where("user_id = ?", userID).Find(&assignments) + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Data: models.ListResponse{List: assignments, Total: int64(len(assignments))}, + }) +} + +// AssignItemRole POST /admin/users/:id/item-roles +func (h *ItemRoleHandler) AssignItemRole(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"}) + return + } + + var req models.AssignItemRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + // Check if already assigned + var existing models.UserItemRole + if err := h.db.Where("user_id = ? AND item_role_id = ?", userID, req.ItemRoleID).First(&existing).Error; err == nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "该用户已拥有此 Item Role"}) + return + } + + assignment := models.UserItemRole{ + UserID: uint(userID), + ItemRoleID: req.ItemRoleID, + } + if err := h.db.Create(&assignment).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "分配失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "分配成功", Data: assignment}) +} + +// RemoveItemRole DELETE /admin/users/:id/item-roles/:roleId +func (h *ItemRoleHandler) RemoveItemRole(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"}) + return + } + roleID, err := strconv.Atoi(c.Param("roleId")) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 Role ID"}) + return + } + + if err := h.db.Where("user_id = ? AND item_role_id = ?", uint(userID), roleID).Delete(&models.UserItemRole{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "移除失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "移除成功"}) +} diff --git a/backend/internal/api/handlers/pipeline.go b/backend/internal/api/handlers/pipeline.go index ff5a825..3eec182 100644 --- a/backend/internal/api/handlers/pipeline.go +++ b/backend/internal/api/handlers/pipeline.go @@ -2,22 +2,63 @@ package handlers import ( "net/http" - "strconv" + "regexp" + "time" "ezops/internal/models" + "ezops/internal/api/middleware" + ezredis "ezops/pkg/redis" "github.com/gin-gonic/gin" "gorm.io/gorm" ) -// PipelineHandler 流水线处理器(流水线 + 应用发布两个子域,应用发布方法拆分至 pipeline_release.go) // Logan.Li +// getPipelineID 从 context 获取中间件注入的流水线 ID +func getPipelineID(c *gin.Context) uint { + if v, exists := c.Get("pipelineID"); exists { + if id, ok := v.(uint); ok { + return id + } + } + return 0 +} + +// codePattern 流水线编码规则:字母+数字+连字符,不能以连字符开头/结尾 +var codePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`) + +// validateCode 校验流水线编码格式并查重 +func validateCode(db *gorm.DB, code string, excludeID uint) string { + if code == "" { + return "流水线编码不能为空" + } + if len(code) > 64 { + return "编码长度不能超过 64 字符" + } + if !codePattern.MatchString(code) { + return "编码格式不正确:只能使用字母、数字、连字符(-),不能以连字符开头或结尾\n示例: My-Pipeline, demo-jar-backend" + } + // 查重 + var count int64 + q := db.Model(&models.Pipeline{}).Where("code = ?", code) + if excludeID > 0 { + q = q.Where("id != ?", excludeID) + } + q.Count(&count) + if count > 0 { + return "编码已存在,请使用其他编码" + } + return "" +} + +// PipelineHandler 流水线管理器 type PipelineHandler struct { - db *gorm.DB + db *gorm.DB + cache ezredis.Cache } // NewPipelineHandler 创建流水线处理器 -func NewPipelineHandler(db *gorm.DB) *PipelineHandler { - return &PipelineHandler{db: db} +func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache) *PipelineHandler { + return &PipelineHandler{db: db, cache: cache} } // ListPipelines 获取流水线列表 @@ -29,24 +70,38 @@ func NewPipelineHandler(db *gorm.DB) *PipelineHandler { // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /pipelines [get] +// ListPipelines 获取流水线列表 func (h *PipelineHandler) ListPipelines(c *gin.Context) { var query models.PageQuery c.ShouldBindQuery(&query) + // 管理员看全部,非管理员只看自己有权限的 + isAdmin, _ := c.Get("isAdmin") + username, _ := c.Get("username") + usernameStr, _ := username.(string) + userID, _ := c.Get("userID") + uid, _ := userID.(uint) + + db := h.db.Model(&models.Pipeline{}) + if isAdmin != true && usernameStr != "" { + ids, hasAccess := middleware.GetAccessiblePipelineIDs(h.db, usernameStr, uid) + if !hasAccess { + // 没有任何流水线权限 → 返回空列表 + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: []models.Pipeline{}, Total: 0}}) + return + } + db = db.Where("id IN ?", ids) + } + var total int64 - h.db.Model(&models.Pipeline{}).Count(&total) + db.Count(&total) var pipelines []models.Pipeline - h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&pipelines) + db.Preload("Environment").Offset(query.GetOffset()).Limit(query.GetLimit()).Order("updated_at DESC").Find(&pipelines) c.JSON(http.StatusOK, models.Response{ Code: 200, - Message: "success", - Data: models.ListResponse{ - List: pipelines, - Total: total, - }, + Data: models.ListResponse{List: pipelines, Total: total}, }) } @@ -60,7 +115,11 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) { // @Success 200 {object} models.Response{data=models.Pipeline} // @Router /pipelines/{id} [get] func (h *PipelineHandler) GetPipeline(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) + id := getPipelineID(c) + if id == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"}) + return + } var pipeline models.Pipeline if err := h.db.First(&pipeline, id).Error; err != nil { @@ -98,17 +157,42 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) { return } + // 校验编码格式 + if msg := validateCode(h.db, req.Code, 0); msg != "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: msg}) + return + } + pipeline := models.Pipeline{ - Name: req.Name, - Code: req.Code, - Description: req.Description, - Type: req.Type, - Source: req.Source, - Branch: req.Branch, - Dockerfile: req.Dockerfile, - Stages: req.Stages, - Variables: req.Variables, - Enabled: true, + Name: req.Name, + Code: req.Code, + JobName: req.JobName, + Description: req.Description, + Type: req.Type, + Source: req.Source, + Branch: req.Branch, + Dockerfile: req.Dockerfile, + Stages: req.Stages, + Variables: req.Variables, + DeployMode: req.DeployMode, + DeployTarget: req.DeployTarget, + ServerIDs: req.ServerIDs, + K8sClusterID: req.K8sClusterID, + K8sNamespace: req.K8sNamespace, + RepoID: req.RepoID, + RegistryID: req.RegistryID, + GitCredentialID: req.GitCredentialID, + RegistryCredentialID: req.RegistryCredentialID, + EnvironmentID: req.EnvironmentID, + // 触发配置 + TriggerMode: req.TriggerMode, + WatchBranches: req.WatchBranches, + ExcludeBranch: req.ExcludeBranch, + CronExpr: req.CronExpr, + WebhookToken: req.WebhookToken, + // 缓存 + CachePaths: req.CachePaths, + Enabled: true, } if err := h.db.Create(&pipeline).Error; err != nil { @@ -138,9 +222,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) { // @Success 200 {object} models.Response{data=models.Pipeline} // @Router /pipelines/{id} [put] func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) + id := getPipelineID(c) + if id == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"}) + return + } - var req models.CreatePipelineRequest + var req models.UpdatePipelineRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{ Code: 400, @@ -158,16 +246,33 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { return } - updates := map[string]interface{}{ - "name": req.Name, - "description": req.Description, - "type": req.Type, - "source": req.Source, - "branch": req.Branch, - "dockerfile": req.Dockerfile, - "stages": req.Stages, - "variables": req.Variables, - } + updates := map[string]interface{}{} + if req.Name != "" { updates["name"] = req.Name } + if req.JobName != "" { updates["job_name"] = req.JobName } + if req.Description != "" { updates["description"] = req.Description } + if req.Type != "" { updates["type"] = req.Type } + if req.Source != "" { updates["source"] = req.Source } + if req.Branch != "" { updates["branch"] = req.Branch } + if req.Dockerfile != "" { updates["dockerfile"] = req.Dockerfile } + if req.Stages != "" { updates["stages"] = req.Stages } + if req.Variables != "" { updates["variables"] = req.Variables } + if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode } + if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget } + if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs } + if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace } + if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID } + if req.RepoID != nil { updates["repo_id"] = req.RepoID } + if req.RegistryID != nil { updates["registry_id"] = req.RegistryID } + if req.GitCredentialID != nil { updates["git_credential_id"] = req.GitCredentialID } + if req.RegistryCredentialID != nil { updates["registry_credential_id"] = req.RegistryCredentialID } + if req.EnvironmentID != nil { updates["environment_id"] = req.EnvironmentID } + if req.TriggerMode != "" { updates["trigger_mode"] = req.TriggerMode } + if req.WatchBranches != "" { updates["watch_branches"] = req.WatchBranches } + if req.ExcludeBranch != "" { updates["exclude_branch"] = req.ExcludeBranch } + if req.CronExpr != "" { updates["cron_expr"] = req.CronExpr } + if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken } + if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths } + if req.Enabled != nil { updates["enabled"] = req.Enabled } if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, models.Response{ @@ -196,7 +301,7 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { // @Success 200 {object} models.Response // @Router /pipelines/{id} [delete] func (h *PipelineHandler) DeletePipeline(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) + id := getPipelineID(c) if err := h.db.Delete(&models.Pipeline{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, models.Response{ @@ -222,50 +327,59 @@ func (h *PipelineHandler) DeletePipeline(c *gin.Context) { // @Param id path int true "流水线ID" // @Param request body models.TriggerPipelineRequest true "触发请求" // @Success 200 {object} models.Response{data=models.PipelineRun} -// @Router /pipelines/{id}/trigger [post] +// TriggerPipeline 触发流水线 func (h *PipelineHandler) TriggerPipeline(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) + id := getPipelineID(c) + if id == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"}) + return + } var pipeline models.Pipeline if err := h.db.First(&pipeline, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "流水线不存在", - }) + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"}) return } var req models.TriggerPipelineRequest c.ShouldBindJSON(&req) - // 获取最新构建号 - var maxBuildNumber int - h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", id).Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber) + var maxBuildNumber int + h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", id). + Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber) + + branch := req.Branch + tag := req.Tag + ref := branch + if req.Tag != "" { + ref = tag + } else if branch == "" { + ref = pipeline.Branch + } + + username, _ := c.Get("username") + userStr, _ := username.(string) + + now := time.Now() run := models.PipelineRun{ PipelineID: uint(id), BuildNumber: maxBuildNumber + 1, - Status: "pending", - Branch: req.Branch, - TriggeredBy: "manual", - } - - if req.Branch == "" { - run.Branch = pipeline.Branch + Status: "running", + Branch: ref, + ImageTag: tag, // 发布 Tag + TriggeredBy: userStr, + StartedAt: &now, } if err := h.db.Create(&run).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "触发失败", - }) + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败"}) return } - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "触发成功", - Data: run, - }) + // 后台异步执行流水线 + go h.executePipeline(pipeline, run) + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已触发", Data: run}) } // ListRuns 获取运行记录列表 @@ -280,7 +394,7 @@ func (h *PipelineHandler) TriggerPipeline(c *gin.Context) { // @Success 200 {object} models.Response{data=models.ListResponse} // @Router /pipelines/{id}/runs [get] func (h *PipelineHandler) ListRuns(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) + id := getPipelineID(c) var query models.PageQuery c.ShouldBindQuery(&query) @@ -290,7 +404,7 @@ func (h *PipelineHandler) ListRuns(c *gin.Context) { db.Count(&total) var runs []models.PipelineRun - db.Order("created_at DESC"). + db.Order("started_at DESC"). Offset(query.GetOffset()). Limit(query.GetLimit()). Find(&runs) @@ -304,3 +418,91 @@ func (h *PipelineHandler) ListRuns(c *gin.Context) { }, }) } + +// WebhookTrigger 外部 Webhook 触发流水线 (支持 URL: /webhook/job/:jobName 和 /webhook/pipeline/:code) +func (h *PipelineHandler) WebhookTrigger(c *gin.Context) { + jobName := c.Param("jobName") + code := c.Param("code") + + var pipeline models.Pipeline + if jobName != "" { + if err := h.db.Where("job_name = ? AND enabled = ?", jobName, true).First(&pipeline).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Job 不存在或已禁用"}) + return + } + } else if code != "" { + if err := h.db.Where("code = ? AND enabled = ?", code, true).First(&pipeline).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在或已禁用"}) + return + } + } else { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "缺少 jobName 或 code"}) + return + } + + // 验证 Webhook Token + if pipeline.WebhookToken != "" { + token := c.GetHeader("X-Webhook-Token") + if token == "" { + token = c.Query("token") + } + if token != pipeline.WebhookToken { + c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "Webhook Token 无效"}) + return + } + } + + // 解析请求体获取 branch/tag + var req models.TriggerPipelineRequest + c.ShouldBindJSON(&req) + + var maxBuildNumber int + h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", pipeline.ID). + Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber) + + ref := req.Branch + if req.Tag != "" { + ref = req.Tag + } else if ref == "" { + ref = pipeline.Branch + } + + triggeredBy := "webhook" + if reqPayload, exists := c.Get("raw_body"); exists { + // 尝试从 webhook payload 中提取提交者信息 + if body, ok := reqPayload.(map[string]interface{}); ok { + if pusher, ok := body["pusher"].(map[string]interface{}); ok { + if name, ok := pusher["name"].(string); ok { + triggeredBy = name + } + } + } + } + + now := time.Now() + run := models.PipelineRun{ + PipelineID: pipeline.ID, + BuildNumber: maxBuildNumber + 1, + Status: "running", + Branch: ref, + ImageTag: req.Tag, + TriggeredBy: triggeredBy, + StartedAt: &now, + } + + if err := h.db.Create(&run).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败"}) + return + } + + go h.executePipeline(pipeline, run) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "已触发", + Data: map[string]interface{}{ + "build_number": run.BuildNumber, + "run_id": run.ID, + }, + }) +} diff --git a/backend/internal/api/handlers/pipeline_executor.go b/backend/internal/api/handlers/pipeline_executor.go new file mode 100644 index 0000000..5341bf9 --- /dev/null +++ b/backend/internal/api/handlers/pipeline_executor.go @@ -0,0 +1,574 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "ezops/internal/models" + + "go.uber.org/zap" +) + +// ==================== 流水线执行引擎 V2(Redis 实时日志推送)==================== + +// LogEntry 日志条目 +type LogEntry struct { + Time string `json:"time"` + Level string `json:"level"` + Stage string `json:"stage"` + Message string `json:"message"` +} + +// StageStep 单个阶段定义 +type StageStep struct { + Name string `json:"name"` + Type string `json:"type"` // checkout/build/test/image/deploy/verify + Status string `json:"status"` +} + +var logger *zap.Logger + +func init() { + logger, _ = zap.NewProduction() +} + +// executePipeline 后台执行流水线(goroutine)— V2 Redis 版 +func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.PipelineRun) { + runID := run.ID + ctx := context.Background() + logKey := fmt.Sprintf("pipeline:run:%d:logs", runID) + pubCh := fmt.Sprintf("pipeline:run:%d", runID) + + // 日志函数:同时写 Redis buffer + 发布 Pub/Sub + log := func(level, stage, msg string) { + entry := LogEntry{Time: time.Now().Format("15:04:05"), Level: level, Stage: stage, Message: msg} + data, _ := json.Marshal(entry) + + // 追加到 Redis 列表 + if h.cache != nil { + existing, _ := h.cache.Get(ctx, logKey) + var entries []LogEntry + if existing != "" { + json.Unmarshal([]byte(existing), &entries) + } + entries = append(entries, entry) + buf, _ := json.Marshal(entries) + h.cache.Set(ctx, logKey, string(buf), 30*time.Minute) + // Pub/Sub 实时推送 + h.cache.Publish(ctx, pubCh, string(data)) + } + } + + updateStatus := func(status string, duration int) { + now := time.Now() + updates := map[string]interface{}{"status": status} + if duration > 0 { + updates["duration"] = duration + } + if status == "success" || status == "failed" || status == "cancelled" { + updates["finished_at"] = &now + } + h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Updates(updates) + // 推送状态变更 + if h.cache != nil { + statusData, _ := json.Marshal(map[string]interface{}{"type": "status", "status": status, "duration": duration}) + h.cache.Publish(ctx, pubCh, string(statusData)) + } + } + + updatePipelineStats := func(status string) { + ups := map[string]interface{}{"last_status": status, "last_run_at": time.Now(), "run_count": pipeline.RunCount + 1} + if status == "success" { + ups["success_count"] = pipeline.SuccessCount + 1 + } + if status == "failed" { + ups["fail_count"] = pipeline.FailCount + 1 + } + h.db.Model(&models.Pipeline{}).Where("id = ?", pipeline.ID).Updates(ups) + } + + isCancelled := func() bool { + var current models.PipelineRun + h.db.First(¤t, runID) + return current.Status == "cancelled" + } + + // 解析阶段 + var stages []StageStep + if pipeline.Stages != "" { + json.Unmarshal([]byte(pipeline.Stages), &stages) + } + if len(stages) == 0 { + log("warn", "init", "没有配置阶段,流水线结束") + log("success", "完成", "流水线执行成功 (0s)") + updateStatus("success", 0) + updatePipelineStats("success") + h.flushLogsToDB(ctx, runID, logKey) + return + } + + // 工作目录 — 按仓库名+分支+时间戳命名,方便区分不同构建 + branch := run.Branch + if branch == "" { + branch = pipeline.Branch + } + gitURL := pipeline.Source + // 从 Git URL 提取仓库名 (e.g. https://gitee.com/attacker/demo-jar.git → demo-jar) + repoName := extractRepoName(gitURL) + if repoName == "" { + repoName = pipeline.Code + } + ts := time.Now() + tsDir := fmt.Sprintf("%d%02d%02d%02d%02d", ts.Year(), ts.Month(), ts.Day(), ts.Hour(), ts.Minute()) + workspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code) + + os.RemoveAll(workspace) + os.MkdirAll(workspace, 0755) + // 保存 workspace 路径到运行记录,方便终端查看 + h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("workspace_path", workspace) + + imageTag := fmt.Sprintf("%s-%s", repoName, tsDir) + + log("info", "init", fmt.Sprintf("触发方式: 手动 (admin)")) + log("info", "init", fmt.Sprintf("分支: %s | 镜像标签: %s", branch, imageTag)) + + totalStart := time.Now() + + for _, stage := range stages { + if isCancelled() { + log("warn", stage.Name, "流水线已被停止") + updateStatus("cancelled", int(time.Since(totalStart).Seconds())) + updatePipelineStats("cancelled") + h.flushLogsToDB(ctx, runID, logKey) + return + } + + log("info", stage.Name, fmt.Sprintf("===== 阶段: %s =====", stage.Name)) + stageStart := time.Now() + + var err error + switch stage.Type { + case "checkout": + err = h.runCheckout(gitURL, branch, workspace, log) + case "build": + err = h.runBuild(workspace, pipeline.Dockerfile, log) + case "test": + err = h.runTest(workspace, log) + case "image": + err = h.runDockerBuild(workspace, pipeline, imageTag, log) + case "deploy": + err = h.runDeploy(workspace, pipeline, imageTag, log) + case "verify": + err = h.runVerify(workspace, log) + default: + log("info", stage.Name, "跳过未知阶段类型: "+stage.Type) + } + + stageDur := time.Since(stageStart) + if err != nil { + log("error", stage.Name, fmt.Sprintf("✗ %s 失败 (%v): %v", stage.Name, stageDur.Round(time.Second), err)) + updateStatus("failed", int(time.Since(totalStart).Seconds())) + updatePipelineStats("failed") + h.flushLogsToDB(ctx, runID, logKey) + return + } + log("success", stage.Name, fmt.Sprintf("✓ %s 完成 (%v)", stage.Name, stageDur.Round(time.Second))) + } + + totalDur := int(time.Since(totalStart).Seconds()) + updateStatus("success", totalDur) + updatePipelineStats("success") + log("success", "完成", fmt.Sprintf("流水线执行成功 (%ds)", totalDur)) + + // 执行结束 → Redis 日志批量写入 MySQL + h.flushLogsToDB(ctx, runID, logKey) +} + +// flushLogsToDB Redis 日志批量写入 MySQL +func (h *PipelineHandler) flushLogsToDB(ctx context.Context, runID uint, logKey string) { + if h.cache == nil { + return + } + raw, err := h.cache.Get(ctx, logKey) + if err != nil || raw == "" { + return + } + var entries []LogEntry + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + return + } + logsJSON, _ := json.Marshal(entries) + h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("logs", string(logsJSON)) + // 清理 Redis 缓存 + h.cache.Del(ctx, logKey) +} + +// runCheckout git clone +func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func(string, string, string)) error { + if url == "" { + logFn("warn", "代码检出", "没有配置 Git URL,跳过代码检出") + return nil + } + logFn("info", "代码检出", "> Git 拉取 "+url+" (分支: "+branch+")") + + cmd := exec.Command("git", "clone", "--depth", "1", "-b", branch, url, workspace) + output, err := cmd.CombinedOutput() + // 流式输出到日志 + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + logFn("info", "代码检出", line) + } + } + if err != nil { + return fmt.Errorf("git clone 失败: %v", err) + } + hashCmd := exec.Command("git", "-C", workspace, "rev-parse", "--short", "HEAD") + hashOut, _ := hashCmd.Output() + logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(string(hashOut)))) + return nil +} + +// runBuild 构建(自动检测 Maven / npm / Go / Makefile) +func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(string, string, string)) error { + buildDir := workspace + if dockerfile != "" { + if dir := dotdot(dockerfile); dir != "." && dir != "" { + testDir := workspace + "/" + dir + if _, err := os.Stat(testDir); err == nil { + buildDir = testDir + logFn("info", "构建", ">>> 切换到构建目录: "+dir) + } + } + } + + // 如果根目录没找到构建文件,扫描一级子目录 + if !hasBuildFile(buildDir) { + type subMatch struct { + dir string + kind string // "maven", "npm", "go", "make" + } + var matches []subMatch + entries, err := os.ReadDir(buildDir) + if err == nil { + for _, e := range entries { + if !e.IsDir() || e.Name() == ".git" { + continue + } + sub := buildDir + "/" + e.Name() + if _, err := os.Stat(sub + "/pom.xml"); err == nil { + matches = append(matches, subMatch{sub, "maven"}) + } + if _, err := os.Stat(sub + "/package.json"); err == nil { + matches = append(matches, subMatch{sub, "npm"}) + } + if _, err := os.Stat(sub + "/go.mod"); err == nil { + matches = append(matches, subMatch{sub, "go"}) + } + if _, err := os.Stat(sub + "/Makefile"); err == nil { + matches = append(matches, subMatch{sub, "make"}) + } + } + } + if len(matches) > 0 { + // 有多个匹配时列出,优先选择第一个 + if len(matches) > 1 { + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = fmt.Sprintf("%s(%s)", m.dir[len(buildDir)+1:], m.kind) + } + logFn("info", "构建", ">>> 发现多个构建目录: "+strings.Join(names, ", ")) + } + buildDir = matches[0].dir + logFn("info", "构建", ">>> 自动检测到构建目录: "+buildDir[len(workspace)+1:]) + } else { + logFn("info", "构建", "未检测到构建文件 (pom.xml/go.mod/package.json),跳过构建") + return nil + } + } + + if _, err := os.Stat(buildDir + "/pom.xml"); err == nil { + logFn("info", "构建", ">>> Maven 构建 (mvn clean package -DskipTests)") + logFn("info", "构建", "🔨 正在下载依赖并编译...") + cmd := exec.Command("mvn", "clean", "package", "-DskipTests") + cmd.Dir = buildDir + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "构建", line) + } + } + if err != nil { + return fmt.Errorf("Maven 构建失败: %v", err) + } + logFn("success", "构建", "Maven 构建成功 ✅") + return nil + } + + if _, err := os.Stat(buildDir + "/package.json"); err == nil { + logFn("info", "构建", ">>> npm 构建 (npm install && npm run build)") + logFn("info", "构建", "📦 正在安装依赖...") + installCmd := exec.Command("sh", "-c", "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1") + installCmd.Dir = buildDir + out, err := installCmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "构建", line) + } + } + if err != nil { + return fmt.Errorf("npm install 失败: %v", err) + } + logFn("info", "构建", "🏗️ 正在打包构建...") + buildCmd := exec.Command("sh", "-c", "npm run build 2>&1") + buildCmd.Dir = buildDir + out, err = buildCmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "构建", line) + } + } + if err != nil { + return fmt.Errorf("npm build 失败: %v", err) + } + logFn("success", "构建", "npm 构建成功 ✅") + return nil + } + + if _, err := os.Stat(buildDir + "/go.mod"); err == nil { + logFn("info", "构建", ">>> Go 构建 (go build ./...)") + cmd := exec.Command("go", "build", "./...") + cmd.Dir = buildDir + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "构建", line) + } + } + if err != nil { + return fmt.Errorf("Go 构建失败: %v", err) + } + logFn("success", "构建", "Go 构建成功") + return nil + } + + logFn("info", "构建", "未检测到构建文件 (pom.xml/go.mod/package.json),跳过构建") + return nil +} + +// hasBuildFile 检查目录是否包含可识别的构建文件 +func hasBuildFile(dir string) bool { + for _, f := range []string{"pom.xml", "package.json", "go.mod", "Makefile"} { + if _, err := os.Stat(dir + "/" + f); err == nil { + return true + } + } + return false +} + +// runTest 执行测试 +func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, string)) error { + if _, err := os.Stat(workspace + "/pom.xml"); err == nil { + logFn("info", "测试", ">>> Maven 测试 (mvn test)") + cmd := exec.Command("mvn", "test") + cmd.Dir = workspace + out, _ := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "测试", line) + } + } + } + return nil +} + +// runDockerBuild Docker 构建 & 推送 +func (h *PipelineHandler) runDockerBuild(workspace string, pipeline models.Pipeline, tag string, logFn func(string, string, string)) error { + dockerfile := pipeline.Dockerfile + if dockerfile == "" { + dockerfile = "Dockerfile" + } + + var registryURL, imageName string + if pipeline.RegistryID != nil { + var reg models.PipelineRegistry + if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil { + registryURL = reg.RegistryURL + if reg.Namespace != "" { + imageName = reg.Namespace + "/" + pipeline.Code + } + } + } + if imageName == "" { + imageName = pipeline.Code + } + if registryURL == "" { + registryURL = "registry.example.com" + } + + fullImage := fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag) + logFn("info", "镜像打包", fmt.Sprintf("镜像地址: %s", fullImage)) + + buildDir := workspace + if dir := dotdot(dockerfile); dir != "." && dir != "" { + buildDir = workspace + "/" + dir + } + dfPath := dockerfile + if strings.Contains(dockerfile, "/") { + dfPath = dockerfile[strings.LastIndex(dockerfile, "/")+1:] + } + + logFn("info", "镜像打包", fmt.Sprintf(">>> Docker 构建 (dockerfile: %s)", dfPath)) + cmd := exec.Command("docker", "build", "-t", fullImage, "-f", dfPath, ".") + cmd.Dir = buildDir + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "镜像打包", line) + } + } + if err != nil { + return fmt.Errorf("Docker 构建失败: %v", err) + } + logFn("success", "镜像打包", "Docker 构建成功") + + logFn("info", "镜像打包", ">>> 推送镜像: "+fullImage) + pushCmd := exec.Command("docker", "push", fullImage) + pushOut, err := pushCmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(pushOut)), "\n") { + if line != "" { + logFn("info", "镜像打包", line) + } + } + if err != nil { + return fmt.Errorf("Docker 推送失败: %v", err) + } + logFn("success", "镜像打包", "镜像推送完成") + return nil +} + +// runDeploy 部署到 K8s / 服务器 +func (h *PipelineHandler) runDeploy(workspace string, pipeline models.Pipeline, tag string, logFn func(string, string, string)) error { + deployYamls := findFiles(workspace, ".yaml", ".yml") + var k8sFiles []string + for _, f := range deployYamls { + content, _ := os.ReadFile(f) + if strings.Contains(string(content), "kind:") && + (strings.Contains(string(content), "Deployment") || + strings.Contains(string(content), "Service") || + strings.Contains(string(content), "StatefulSet")) { + k8sFiles = append(k8sFiles, f) + } + } + + if len(k8sFiles) == 0 { + logFn("info", "部署", "未找到 K8s 部署文件,跳过部署") + return nil + } + + for _, yamlFile := range k8sFiles { + logFn("info", "部署", ">>> kubectl apply -f "+yamlFile) + cmd := exec.Command("kubectl", "apply", "-f", yamlFile) + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "部署", line) + } + } + if err != nil { + logFn("error", "部署", fmt.Sprintf("kubectl apply 失败: %v", err)) + return fmt.Errorf("kubectl apply 失败") + } + } + logFn("success", "部署", "部署完成") + return nil +} + +// runVerify 部署后验证 +func (h *PipelineHandler) runVerify(workspace string, logFn func(string, string, string)) error { + logFn("info", "验证", ">>> 部署后健康检查") + cmd := exec.Command("kubectl", "get", "pods", "-o", "wide") + cmd.Dir = workspace + out, err := cmd.CombinedOutput() + if err != nil { + logFn("warn", "验证", fmt.Sprintf("kubectl get pods 失败: %v", err)) + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + logFn("info", "验证", line) + } + } + return nil +} + +// ======== helpers ======== + +func dotdot(path string) string { + i := strings.LastIndex(path, "/") + if i < 0 { + return "." + } + return path[:i] +} + +// extractRepoName 从 Git URL 提取仓库名 +// e.g. https://gitee.com/attacker/demo-jar.git → demo-jar +func extractRepoName(url string) string { + // Remove trailing .git + clean := strings.TrimSuffix(url, ".git") + // Get last path segment + idx := strings.LastIndex(clean, "/") + if idx < 0 { + return "" + } + return clean[idx+1:] +} + +// cleanOldWorkspaces 清理超过 maxAge 的 workspace 目录 +func cleanOldWorkspaces(baseDir string, maxAge time.Duration) { + entries, err := os.ReadDir(baseDir) + if err != nil { + return + } + now := time.Now() + for _, e := range entries { + if !e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if now.Sub(info.ModTime()) > maxAge { + os.RemoveAll(baseDir + "/" + e.Name()) + } + } +} + +func findFiles(dir string, exts ...string) []string { + var result []string + entries, err := os.ReadDir(dir) + if err != nil { + return result + } + for _, e := range entries { + full := dir + "/" + e.Name() + if e.IsDir() { + if e.Name() != ".git" { + result = append(result, findFiles(full, exts...)...) + } + } else { + for _, ext := range exts { + if strings.HasSuffix(e.Name(), ext) { + result = append(result, full) + break + } + } + } + } + return result +} diff --git a/backend/internal/api/handlers/server_batch.go b/backend/internal/api/handlers/server_batch.go index 499fe16..5ae3ced 100644 --- a/backend/internal/api/handlers/server_batch.go +++ b/backend/internal/api/handlers/server_batch.go @@ -43,7 +43,7 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "凭据解密失败"}) return } - if cred.Type == "ssh_key" { + if cred.SecretType == "ssh" { decryptedKey = plain authType = "key" } else { diff --git a/backend/internal/api/handlers/workspace_files.go b/backend/internal/api/handlers/workspace_files.go new file mode 100644 index 0000000..d4d2eaa --- /dev/null +++ b/backend/internal/api/handlers/workspace_files.go @@ -0,0 +1,184 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "ezops/internal/models" +) + +// WorkspaceFilesHandler 工作区文件浏览器处理器 +type WorkspaceFilesHandler struct { + db *gorm.DB +} + +func NewWorkspaceFilesHandler(db *gorm.DB) *WorkspaceFilesHandler { + return &WorkspaceFilesHandler{db: db} +} + +// FileInfo 文件信息 +type FileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + ModTime string `json:"mod_time"` +} + +// resolveWorkspace 解析 run ID 获取 workspace 路径 +func (h *WorkspaceFilesHandler) resolveWorkspace(c *gin.Context) (string, error) { + runID, err := strconv.Atoi(c.Param("runId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run id"}) + return "", err + } + + var run models.PipelineRun + if err := h.db.First(&run, runID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "run not found"}) + return "", err + } + + if run.WorkspacePath == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace path not available, pipeline may still be running"}) + return "", fmt.Errorf("no workspace") + } + + return run.WorkspacePath, nil +} + +// safePath 安全拼接路径,防止目录穿越 +func safePath(base, sub string) (string, error) { + if sub == "" || sub == "." { + sub = "/" + } + // 清理路径 + clean := filepath.Clean(sub) + if filepath.IsAbs(clean) { + // 去掉根前缀,转为相对路径 + clean = clean[1:] + } + fullPath := filepath.Join(base, clean) + // 确保不逃逸出 base + absBase, _ := filepath.Abs(base) + absFull, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("invalid path") + } + if !strings.HasPrefix(absFull, absBase) { + return "", fmt.Errorf("path traversal detected") + } + return fullPath, nil +} + +// ListFiles 列出目录内容 +// GET /api/v1/pipelines/runs/:runId/files?path=subdir +func (h *WorkspaceFilesHandler) ListFiles(c *gin.Context) { + workspace, err := h.resolveWorkspace(c) + if err != nil { + return + } + + subPath := c.Query("path") + targetPath, err := safePath(workspace, subPath) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + entries, err := os.ReadDir(targetPath) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "directory not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read directory"}) + } + return + } + + files := make([]FileInfo, 0) + dirs := make([]FileInfo, 0) + + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + rel, _ := filepath.Rel(workspace, filepath.Join(targetPath, entry.Name())) + fi := FileInfo{ + Name: entry.Name(), + Path: rel, + IsDir: entry.IsDir(), + Size: info.Size(), + ModTime: info.ModTime().Format("2006-01-02 15:04:05"), + } + if entry.IsDir() { + dirs = append(dirs, fi) + } else { + files = append(files, fi) + } + } + + // 当前路径(相对于 workspace) + currentRel, _ := filepath.Rel(workspace, targetPath) + if currentRel == "." { + currentRel = "" + } + + c.JSON(http.StatusOK, gin.H{ + "current": currentRel, + "dirs": dirs, + "files": files, + }) +} + +// DownloadFile 下载文件 +// GET /api/v1/pipelines/runs/:runId/files/download?path=file +func (h *WorkspaceFilesHandler) DownloadFile(c *gin.Context) { + workspace, err := h.resolveWorkspace(c) + if err != nil { + return + } + + subPath := c.Query("path") + if subPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + return + } + + targetPath, err := safePath(workspace, subPath) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + info, err := os.Stat(targetPath) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + if info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot download a directory"}) + return + } + + file, err := os.Open(targetPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"}) + return + } + defer file.Close() + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(targetPath))) + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", fmt.Sprintf("%d", info.Size())) + io.Copy(c.Writer, file) +} diff --git a/backend/internal/api/handlers/ws_pipeline_log.go b/backend/internal/api/handlers/ws_pipeline_log.go new file mode 100644 index 0000000..5ff1be2 --- /dev/null +++ b/backend/internal/api/handlers/ws_pipeline_log.go @@ -0,0 +1,142 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + ezredis "ezops/pkg/redis" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/gorm" +) + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// WSPipelineLogHandler WebSocket 流水线日志推流 +type WSPipelineLogHandler struct { + db *gorm.DB + cache ezredis.Cache +} + +// NewWSPipelineLogHandler 创建 WebSocket 日志处理器 +func NewWSPipelineLogHandler(db *gorm.DB, cache ezredis.Cache) *WSPipelineLogHandler { + return &WSPipelineLogHandler{db: db, cache: cache} +} + +// StreamLog WebSocket 端点:/ws/pipeline/:runId/log +func (h *WSPipelineLogHandler) StreamLog(c *gin.Context) { + runIDStr := c.Param("runId") + runID, err := strconv.ParseUint(runIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 runId"}) + return + } + + ws, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer ws.Close() + + logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID)) + ctx := c.Request.Context() + + // 首次发送:已有日志 + raw, _ := h.cache.Get(ctx, logKey) + if raw != "" { + ws.WriteJSON(map[string]interface{}{"type": "init", "logs": raw}) + } + + // 轮询新日志(500ms 间隔) + lastLen := 0 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + done := make(chan struct{}) + go func() { + defer close(done) + for { + _, _, err := ws.ReadMessage() + if err != nil { + return + } + } + }() + + for { + select { + case <-done: + return + case <-ticker.C: + raw, err := h.cache.Get(ctx, logKey) + if err != nil || raw == "" { + continue + } + var entries []LogEntry + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + continue + } + if len(entries) > lastLen { + // 增量推送 + newEntries := entries[lastLen:] + lastLen = len(entries) + for _, e := range newEntries { + buf, _ := json.Marshal(e) + if err := ws.WriteMessage(websocket.TextMessage, buf); err != nil { + return + } + } + } + + // 检查流水线是否已结束(日志 key 被删除 = 已写入 DB) + if lastLen > 0 { + exists, _ := h.cache.Exists(ctx, logKey) + if exists == 0 { + // 发送完成信号 + ws.WriteJSON(map[string]interface{}{"type": "done"}) + return + } + } + case <-ctx.Done(): + return + } + } +} + +// GetLogHistory HTTP 日志历史(已完成的任务从 DB 读取) +func (h *WSPipelineLogHandler) GetLogHistory(c *gin.Context) { + runIDStr := c.Param("runId") + runID, err := strconv.ParseUint(runIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 runId"}) + return + } + + // 先查 Redis(执行中) + logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID)) + ctx := c.Request.Context() + + raw, _ := h.cache.Get(ctx, logKey) + if raw != "" && !strings.HasPrefix(raw, "[") { + raw = "[]" + } + if raw != "" { + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"source": "redis", "logs": raw}}) + return + } + + // 查 DB(已完成) + var logs string + h.db.Table("ops_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs) + if logs == "" { + logs = "[]" + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"source": "db", "logs": logs}}) +} diff --git a/backend/internal/api/middleware/pipeline_access.go b/backend/internal/api/middleware/pipeline_access.go new file mode 100644 index 0000000..9ef768b --- /dev/null +++ b/backend/internal/api/middleware/pipeline_access.go @@ -0,0 +1,139 @@ +package middleware + +import ( + "net/http" + "regexp" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// PipelineAccess 流水线权限中间件 +// 路由参数 :code(流水线编码)→ 查找 pipeline → 注入 pipelineID 到 context +// 权限检查顺序:管理员 → 直接成员 → 用户组 → Item Role (regex) +func PipelineAccess(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + // 1) 解析 code → 查找流水线(所有用户都需要) + code := c.Param("code") + if code == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少流水线编码"}) + return + } + + var pipeline models.Pipeline + if err := db.Where("code = ?", code).First(&pipeline).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"code": 404, "message": "流水线不存在"}) + return + } + + // 注入 context,后续 handlers 统一从这里读 + c.Set("pipelineID", pipeline.ID) + c.Set("pipelineCode", code) + + // 2) 管理员直接放行(跳过权限检查) + if isAdmin, _ := c.Get("isAdmin"); isAdmin == true { + c.Next() + return + } + + username, _ := c.Get("username") + usernameStr, _ := username.(string) + if usernameStr == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未认证"}) + return + } + + pipelineID := pipeline.ID + + // 3) 检查是否直接是流水线成员 + var directCount int64 + db.Model(&models.DeployPipelineMember{}). + Where("pipeline_id = ? AND username = ?", pipelineID, usernameStr). + Count(&directCount) + if directCount > 0 { + c.Next() + return + } + + // 4) 检查是否属于授权给该流水线的用户组 + var groupCount int64 + db.Table("deploy_pipeline_members"). + Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id"). + Where("deploy_pipeline_members.pipeline_id = ? AND deploy_user_group_members.username = ?", pipelineID, usernameStr). + Count(&groupCount) + if groupCount > 0 { + c.Next() + return + } + + // 5) Jenkins-style Item Role — regex pattern matching on pipeline code + var itemRoles []models.ItemRole + db.Table("iam_item_roles"). + Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id"). + Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?", + func() uint { uid, _ := c.Get("userID"); return uid.(uint) }()). + Find(&itemRoles) + for _, ir := range itemRoles { + if matched, _ := regexp.MatchString(ir.Pattern, code); matched { + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": 403, + "message": "无权访问此流水线,请联系流水线 Owner 添加你为成员", + }) + } +} + +// GetAccessiblePipelineIDs 获取当前用户有权访问的流水线 ID 列表 +// 包括:直接成员 + 用户组 + Item Role (regex) +func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint, bool) { + var directIDs []uint + db.Model(&models.DeployPipelineMember{}). + Where("username = ?", username). + Pluck("pipeline_id", &directIDs) + + var groupIDs []uint + db.Model(&models.DeployPipelineMember{}). + Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id"). + Where("deploy_user_group_members.username = ?", username). + Pluck("deploy_pipeline_members.pipeline_id", &groupIDs) + + idSet := make(map[uint]bool) + for _, id := range directIDs { + idSet[id] = true + } + for _, id := range groupIDs { + idSet[id] = true + } + + // Item Role regex matching: get all item roles for user, match against pipeline codes + var itemRoles []models.ItemRole + db.Table("iam_item_roles"). + Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id"). + Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?", userID). + Find(&itemRoles) + if len(itemRoles) > 0 { + var allPipelines []models.Pipeline + db.Find(&allPipelines) + for _, p := range allPipelines { + for _, ir := range itemRoles { + if matched, _ := regexp.MatchString(ir.Pattern, p.Code); matched { + idSet[p.ID] = true + break + } + } + } + } + + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + + return ids, len(ids) > 0 +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 0407cc2..50bf0a6 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -16,6 +16,9 @@ import ( // Register 注册所有路由 // Logan.Li // cache 参数为可选 Redis 缓存实例(支持 redka/redis 双后端) func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, cache interface{}) { + // 提取 Redis 缓存实例(供流水线 WebSocket 等使用) + redisCache, _ := cache.(ezredis.Cache) + // API版本前缀 api := r.Group("/api/v1") @@ -64,6 +67,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c metricsWSHandler := handlers.NewMetricsWSHandler(metricsHub) r.GET("/ws/metrics", middleware.JWTAuth(&cfg.JWT), metricsWSHandler.ServeWS) + // WebSocket 流水线日志推流 + wsLogHandler := handlers.NewWSPipelineLogHandler(db, redisCache) + r.GET("/ws/pipeline/:runId/log", wsLogHandler.StreamLog) + r.GET("/api/v1/pipelines/runs/:runId/log-stream", wsLogHandler.GetLogHistory) + + // 工作区文件浏览器(浏览/下载流水线构建目录文件) + workspaceFilesHandler := handlers.NewWorkspaceFilesHandler(db) + r.GET("/api/v1/pipelines/runs/:runId/files", workspaceFilesHandler.ListFiles) + r.GET("/api/v1/pipelines/runs/:runId/files/download", workspaceFilesHandler.DownloadFile) + // WebSocket K8s exec 交互终端 // Logan.Li k8sHandler := handlers.NewKubernetesHandler(db) r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal) @@ -77,6 +90,14 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c api.POST("/auth/login", authHandler.Login) api.POST("/auth/register", authHandler.Register) + // Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证) + pipelineHandler := handlers.NewPipelineHandler(db, redisCache) + webhookGroup := api.Group("/webhook") + { + webhookGroup.POST("/job/:jobName", pipelineHandler.WebhookTrigger) + webhookGroup.POST("/pipeline/:code", pipelineHandler.WebhookTrigger) + } + // Agent 安装处理器(在 servers 和 agent 路由中共享) agentInstallHandler := handlers.NewAgentInstallHandler(db) @@ -113,6 +134,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // 权限列表(供角色权限选择) admin.GET("/permissions", authHandler.ListPermissions) + + // Item Role 管理(Jenkins-style regex pipeline matching) // Logan.Li + itemRoleHandler := handlers.NewItemRoleHandler(db) + admin.GET("/item-roles", itemRoleHandler.ListItemRoles) + admin.POST("/item-roles", itemRoleHandler.CreateItemRole) + admin.PUT("/item-roles/:id", itemRoleHandler.UpdateItemRole) + admin.DELETE("/item-roles/:id", itemRoleHandler.DeleteItemRole) + admin.GET("/users/:id/item-roles", itemRoleHandler.ListUserItemRoles) + admin.POST("/users/:id/item-roles", itemRoleHandler.AssignItemRole) + admin.DELETE("/users/:id/item-roles/:roleId", itemRoleHandler.RemoveItemRole) } // LDAP 配置路由 @@ -371,16 +402,51 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c } // 流水线路由 - pipelineHandler := handlers.NewPipelineHandler(db) pipelines := authorized.Group("/pipelines") { pipelines.GET("", pipelineHandler.ListPipelines) pipelines.POST("", pipelineHandler.CreatePipeline) - pipelines.GET("/:id", pipelineHandler.GetPipeline) - pipelines.PUT("/:id", pipelineHandler.UpdatePipeline) - pipelines.DELETE("/:id", pipelineHandler.DeletePipeline) - pipelines.POST("/:id/trigger", pipelineHandler.TriggerPipeline) - pipelines.GET("/:id/runs", pipelineHandler.ListRuns) + + // 需要流水线权限的路由 + pipelineScoped := pipelines.Group("/:code") + pipelineScoped.Use(middleware.PipelineAccess(db)) + { + pipelineScoped.GET("", pipelineHandler.GetPipeline) + pipelineScoped.PUT("", pipelineHandler.UpdatePipeline) + pipelineScoped.DELETE("", pipelineHandler.DeletePipeline) + pipelineScoped.POST("/trigger", pipelineHandler.TriggerPipeline) + pipelineScoped.GET("/runs", pipelineHandler.ListRuns) + pipelineScoped.POST("/runs/:runId/stop", pipelineHandler.StopPipelineRun) + pipelineScoped.POST("/runs/batch-delete", pipelineHandler.BatchDeleteRuns) + pipelineScoped.GET("/runs/:runId/log", pipelineHandler.GetRunLog) + // 成员管理 + pipelineScoped.GET("/members", pipelineHandler.ListPipelineMembers) + pipelineScoped.POST("/members", pipelineHandler.AddPipelineMember) + pipelineScoped.DELETE("/members/:memberId", pipelineHandler.RemovePipelineMember) + // 组授权 + pipelineScoped.POST("/groups", pipelineHandler.AssignGroupToPipeline) + } + } + + // 用户组管理路由 + groups := authorized.Group("/deploy/groups") + { + groups.GET("", pipelineHandler.ListGroups) + groups.POST("", pipelineHandler.CreateGroup) + groups.PUT("/:id", pipelineHandler.UpdateGroup) + groups.DELETE("/:id", pipelineHandler.DeleteGroup) + groups.GET("/:id/members", pipelineHandler.ListGroupMembers) + groups.POST("/:id/members", pipelineHandler.AddGroupMember) + groups.DELETE("/:id/members/:memberId", pipelineHandler.RemoveGroupMember) + } + + // 环境管理路由 + environments := authorized.Group("/deploy/environments") + { + environments.GET("", pipelineHandler.ListEnvironments) + environments.POST("", pipelineHandler.CreateEnvironment) + environments.PUT("/:id", pipelineHandler.UpdateEnvironment) + environments.DELETE("/:id", pipelineHandler.DeleteEnvironment) } // 应用发布路由 @@ -394,6 +460,24 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c releases.POST("/:id/rollback", pipelineHandler.RollbackRelease) } + // 仓库认证 & 镜像仓库认证路由 + deployHandler := handlers.NewDeployHandler(db) + { + repos := authorized.Group("/deploy/repos") + repos.GET("", deployHandler.ListRepos) + repos.POST("", deployHandler.CreateRepo) + repos.GET("/:id", deployHandler.GetRepo) + repos.PUT("/:id", deployHandler.UpdateRepo) + repos.DELETE("/:id", deployHandler.DeleteRepo) + + registries := authorized.Group("/deploy/registries") + registries.GET("", deployHandler.ListRegistries) + registries.POST("", deployHandler.CreateRegistry) + registries.GET("/:id", deployHandler.GetRegistry) + registries.PUT("/:id", deployHandler.UpdateRegistry) + registries.DELETE("/:id", deployHandler.DeleteRegistry) + } + // 自动化运维路由 automationHandler := handlers.NewAutomationHandler(db) cronJobs := authorized.Group("/cron-jobs") @@ -515,7 +599,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c } // Agent 上报路由(无需 JWT) - redisCache, _ := cache.(ezredis.Cache) agentHandler := handlers.NewAgentHandler(db, redisCache, metricsHub) taskHandler := handlers.NewAgentTaskHandler(db) agentGroup := api.Group("/agent") @@ -645,8 +728,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c credGroup := authorized.Group("/credentials") { credGroup.GET("", credentialHandler.ListCredentials) + credGroup.GET("/tags", credentialHandler.GetTags) credGroup.POST("", credentialHandler.CreateCredential) + credGroup.GET("/:id", credentialHandler.GetCredential) credGroup.PUT("/:id", credentialHandler.UpdateCredential) + credGroup.PATCH("/:id", credentialHandler.PatchCredential) credGroup.DELETE("/:id", credentialHandler.DeleteCredential) } // 服务器凭据关联 // Logan.Li diff --git a/backend/internal/models/alert.go b/backend/internal/models/alert.go index 5c4d4ef..0e15d09 100644 --- a/backend/internal/models/alert.go +++ b/backend/internal/models/alert.go @@ -3,59 +3,321 @@ package models import ( "time" + "gorm.io/gorm" ) -// AlertRule 告警规则模型 +// ============================================ +// AlertRule 告警规则(增强版 — 合并原V2特性) +// ============================================ + +// AlertRuleStatus 告警规则状态 +type AlertRuleStatus string + +const ( + AlertRuleEnabled AlertRuleStatus = "enabled" + AlertRuleDisabled AlertRuleStatus = "disabled" +) + +// AlertSeverity 告警级别 +type AlertSeverity string + +const ( + AlertSeverityCritical AlertSeverity = "critical" + AlertSeverityWarning AlertSeverity = "warning" + AlertSeverityNotice AlertSeverity = "notice" + AlertSeverityInfo AlertSeverity = "info" +) + +// AggFunction 聚合函数 +type AggFunction string + +const ( + AggFunctionAvg AggFunction = "avg" + AggFunctionMin AggFunction = "min" + AggFunctionMax AggFunction = "max" + AggFunctionSum AggFunction = "sum" + AggFunctionCount AggFunction = "count" + AggFunctionLast AggFunction = "last" + AggFunctionP95 AggFunction = "p95" + AggFunctionP99 AggFunction = "p99" +) + +// Operator 比较运算符 +type Operator string + +const ( + OperatorGT Operator = ">" + OperatorGTE Operator = ">=" + OperatorLT Operator = "<" + OperatorLTE Operator = "<=" + OperatorEQ Operator = "==" + OperatorNE Operator = "!=" +) + +// AlertRule 告警规则(增强版) type AlertRule struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Description string `gorm:"size:500" json:"description"` - Type string `gorm:"size:32;not null" json:"type"` // metric, log, event - Source string `gorm:"size:64;not null" json:"source"` // prometheus, zabbix, custom - Expression string `gorm:"type:text;not null" json:"expression"` - Severity string `gorm:"size:32;default:warning" json:"severity"` // info, warning, critical - Duration int `json:"duration"` // 持续时间(秒) - Enabled bool `gorm:"default:true" json:"enabled"` - Labels string `gorm:"type:text" json:"labels"` // JSON - Annotations string `gorm:"type:text" json:"annotations"` // JSON - NotifyChannels string `gorm:"type:text" json:"notify_channels"` // JSON - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"` + Description string `gorm:"type:varchar(500);comment:规则描述" json:"description,omitempty"` + // ====== 原 V1 兼容字段 ====== + Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"` + Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom" json:"source,omitempty"` + Expression string `gorm:"type:text;comment:PromQL/Zabbix表达式" json:"expression,omitempty"` + Severity string `gorm:"type:varchar(32);default:'warning';comment:告警级别" json:"severity"` + Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` + Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` + // ====== V2 增强字段 ====== + Status AlertRuleStatus `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status,omitempty"` + TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"` + TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"` + TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"` + Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"` + AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window,omitempty"` + AggFunction string `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function,omitempty"` + RecoverCount int `gorm:"default:1;comment:恢复次数" json:"recover_count,omitempty"` + RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window,omitempty"` + Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval,omitempty"` + CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times,omitempty"` + NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode,omitempty"` + ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"` + NotifyChannels string `gorm:"type:text;comment:通知渠道JSON(兼容V1)" json:"notify_channels,omitempty"` + TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"` + UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"` + SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"` + Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"` + Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` } -// TableName 表名 func (AlertRule) TableName() string { return "monitor_alert_rules" } -// AlertEvent 告警事件模型 +// ============================================ +// AlertEvent 告警事件(增强版 — 合并原V2特性) +// ============================================ + +// AlertEventStatus 告警事件状态 +type AlertEventStatus string + +const ( + AlertEventFiring AlertEventStatus = "firing" + AlertEventResolved AlertEventStatus = "resolved" + AlertEventSilenced AlertEventStatus = "silenced" +) + +// AlertEvent 告警事件(增强版) type AlertEvent struct { - ID uint `gorm:"primarykey" json:"id"` - RuleID uint `json:"rule_id"` - RuleName string `gorm:"size:128" json:"rule_name"` - Severity string `gorm:"size:32" json:"severity"` - Status string `gorm:"size:32;default:firing" json:"status"` // firing, resolved - Target string `gorm:"size:255" json:"target"` - Message string `gorm:"type:text" json:"message"` - Value float64 `json:"value"` - Labels string `gorm:"type:text" json:"labels"` - StartedAt time.Time `json:"started_at"` - ResolvedAt *time.Time `json:"resolved_at"` - Acknowledged bool `json:"acknowledged"` - AcknowledgedBy *uint `json:"acknowledged_by"` - AcknowledgedAt *time.Time `json:"acknowledged_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"` + RuleName string `gorm:"type:varchar(128);comment:规则名称" json:"rule_name,omitempty"` + Severity string `gorm:"type:varchar(32);comment:告警级别" json:"severity,omitempty"` + Status string `gorm:"type:varchar(32);default:'firing';comment:事件状态" json:"status"` + // ====== 原V1兼容字段 ====== + Target string `gorm:"type:varchar(255);comment:目标标签" json:"target,omitempty"` + Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"` + Value float64 `gorm:"comment:触发值" json:"value,omitempty"` + // ====== V2增强字段 ====== + TargetID uint `gorm:"index;comment:目标ID" json:"target_id,omitempty"` + TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"` + TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"` + Title string `gorm:"type:varchar(256);comment:告警标题" json:"title,omitempty"` + MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"` + Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"` + Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"` + Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"` + Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"` + Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` + StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"` + ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"` + Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` + LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"` + NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count,omitempty"` + LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"` + UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level,omitempty"` + Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"` + AcknowledgedBy *uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"` + AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"` + AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` // 关联 - Rule *AlertRule `json:"rule,omitempty"` + Rule *AlertRule `json:"rule,omitempty" gorm:"foreignKey:RuleID"` } -// TableName 表名 func (AlertEvent) TableName() string { return "monitor_alert_events" } +// ============================================ +// AlertCondition 告警条件定义 +// ============================================ + +type AlertCondition struct { + Metric string `json:"metric"` + Operator Operator `json:"operator"` + Threshold float64 `json:"threshold"` + Unit string `json:"unit,omitempty"` + Index string `json:"index,omitempty"` + LogicOp string `json:"logic_op,omitempty"` + AggFunc AggFunction `json:"agg_func,omitempty"` + Window int `json:"window,omitempty"` +} + +func (AlertCondition) TableName() string { + return "monitor_alert_conditions" +} + +type AlertConditionGroup struct { + Conditions []AlertCondition `json:"conditions"` + LogicOp string `json:"logic_op"` +} + +// ============================================ +// AlertNotifyTemplate 告警通知模板 +// ============================================ + +type AlertNotifyTemplate struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"` + TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"` + BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"` + Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"` + RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"` + RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"` + IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` +} + +func (AlertNotifyTemplate) TableName() string { + return "monitor_alert_notify_templates" +} + +type TemplateVariable struct { + Name string `json:"name"` + Description string `json:"description"` + Example string `json:"example"` +} + +var DefaultTemplateVariables = []TemplateVariable{ + {Name: "{{.RuleName}}", Description: "规则名称", Example: "CPU使用率告警"}, + {Name: "{{.TargetName}}", Description: "目标名称", Example: "WebServer-01"}, + {Name: "{{.TargetIP}}", Description: "目标IP", Example: "192.168.1.10"}, + {Name: "{{.Severity}}", Description: "告警级别", Example: "critical"}, + {Name: "{{.MetricName}}", Description: "指标名称", Example: "cpu_usage"}, + {Name: "{{.Value}}", Description: "当前值", Example: "95.2"}, + {Name: "{{.Threshold}}", Description: "阈值", Example: "90"}, + {Name: "{{.Operator}}", Description: "运算符", Example: ">"}, + {Name: "{{.Time}}", Description: "触发时间", Example: "2024-01-15 10:30:00"}, + {Name: "{{.Duration}}", Description: "持续时间", Example: "5m"}, + {Name: "{{.Description}}", Description: "规则描述", Example: "CPU使用率超过阈值"}, + {Name: "{{.URL}}", Description: "详情链接", Example: "https://ezops.io/alerts/123"}, +} + +// ============================================ +// AlertUpgrade + AlertUpgradeRecord 告警升级 +// ============================================ + +type AlertUpgrade struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:策略名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + Rules string `gorm:"type:text;comment:升级规则JSON数组" json:"rules,omitempty"` + MaxLevel int `gorm:"default:3;comment:最大升级级别" json:"max_level"` + IsDefault bool `gorm:"default:false;comment:是否默认策略" json:"is_default"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` +} + +func (AlertUpgrade) TableName() string { + return "monitor_alert_upgrades" +} + +type AlertUpgradeRule struct { + Level int `json:"level"` + Duration int `json:"duration"` + Severity string `json:"severity"` + ChannelIDs []string `json:"channel_ids"` + NotifyUsers []uint `json:"notify_users"` + RepeatInterval int `json:"repeat_interval"` +} + +type AlertUpgradeRecord struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + AlertID uint `gorm:"not null;index;comment:告警ID" json:"alert_id"` + RuleID uint `gorm:"not null;comment:规则ID" json:"rule_id"` + FromLevel int `gorm:"comment:原级别" json:"from_level"` + ToLevel int `gorm:"comment:升级后级别" json:"to_level"` + UpgradedAt time.Time `gorm:"not null;comment:升级时间" json:"upgraded_at"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` +} + +func (AlertUpgradeRecord) TableName() string { + return "monitor_alert_upgrade_records" +} + +// ============================================ +// AlertSilence 告警静默规则 +// ============================================ + +type AlertSilenceType string + +const ( + AlertSilenceOneTime AlertSilenceType = "onetime" + AlertSilenceDaily AlertSilenceType = "daily" + AlertSilenceWeekly AlertSilenceType = "weekly" +) + +type AlertSilence struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:静默规则名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + RuleIDs string `gorm:"type:varchar(256);comment:匹配的告警规则ID列表JSON" json:"rule_ids,omitempty"` + TargetIDs string `gorm:"type:varchar(256);comment:匹配的目标ID列表JSON" json:"target_ids,omitempty"` + Matchers string `gorm:"type:text;comment:标签匹配条件JSON" json:"matchers,omitempty"` + SilenceType AlertSilenceType `gorm:"type:varchar(16);default:'onetime';comment:静默类型" json:"silence_type"` + StartTime time.Time `gorm:"not null;comment:开始时间" json:"start_time"` + EndTime time.Time `gorm:"not null;comment:结束时间" json:"end_time"` + Timezone string `gorm:"type:varchar(32);default:'Asia/Shanghai';comment:时区" json:"timezone"` + Weekdays string `gorm:"type:varchar(64);comment:生效星期(0-6)JSON数组" json:"weekdays,omitempty"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` +} + +func (AlertSilence) TableName() string { + return "monitor_alert_silences" +} + +type AlertSilenceMatcher struct { + Name string `json:"name"` + Value string `json:"value"` + IsRegex bool `json:"is_regex"` +} + +type AlertSilenceCheckResult struct { + IsSilenced bool `json:"is_silenced"` + SilenceID uint `json:"silence_id,omitempty"` + SilenceName string `json:"silence_name,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + // NotifyChannel 通知渠道模型 type NotifyChannel struct { ID uint `gorm:"primarykey" json:"id"` diff --git a/backend/internal/models/alert_v2.go b/backend/internal/models/alert_v2.go deleted file mode 100644 index e214145..0000000 --- a/backend/internal/models/alert_v2.go +++ /dev/null @@ -1,405 +0,0 @@ -package models - -import ( - "time" -) - -// ============================================ -// AlertRuleV2 增强告警规则 -// ============================================ - -// AlertRuleV2Status 告警规则状态 -type AlertRuleV2Status string - -const ( - AlertRuleV2Enabled AlertRuleV2Status = "enabled" // 启用 - AlertRuleV2Disabled AlertRuleV2Status = "disabled" // 禁用 -) - -// AlertSeverity 告警级别 -type AlertSeverity string - -const ( - AlertSeverityCritical AlertSeverity = "critical" // 严重 - AlertSeverityWarning AlertSeverity = "warning" // 警告 - AlertSeverityNotice AlertSeverity = "notice" // 通知 - AlertSeverityInfo AlertSeverity = "info" // 信息 -) - -// AggFunction 聚合函数 -type AggFunction string - -const ( - AggFunctionAvg AggFunction = "avg" // 平均值 - AggFunctionMin AggFunction = "min" // 最小值 - AggFunctionMax AggFunction = "max" // 最大值 - AggFunctionSum AggFunction = "sum" // 求和 - AggFunctionCount AggFunction = "count" // 计数 - AggFunctionLast AggFunction = "last" // 最新值 - AggFunctionP95 AggFunction = "p95" // 95分位 - AggFunctionP99 AggFunction = "p99" // 99分位 -) - -// Operator 比较运算符 -type Operator string - -const ( - OperatorGT Operator = ">" // 大于 - OperatorGTE Operator = ">=" // 大于等于 - OperatorLT Operator = "<" // 小于 - OperatorLTE Operator = "<=" // 小于等于 - OperatorEQ Operator = "==" // 等于 - OperatorNE Operator = "!=" // 不等于 -) - -// AlertRuleV2 增强告警规则表 -// 支持多条件组合、聚合函数、静默期等高级特性 -type AlertRuleV2 struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:规则描述" json:"description,omitempty"` - Severity AlertSeverity `gorm:"type:varchar(16);not null;comment:告警级别" json:"severity"` - Status AlertRuleV2Status `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status"` - // 关联目标 - TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"` - TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"` - TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"` - // 触发条件(JSON存储支持多条件) - Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"` - // 聚合配置 - AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window"` - AggFunction AggFunction `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function"` - // 恢复配置 - RecoverCount int `gorm:"default:1;comment:恢复次数(连续N次低于阈值触发恢复)" json:"recover_count"` - RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window"` - // 执行配置 - Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval"` - CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times"` - NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode"` - // 通知渠道 - ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"` - TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"` - // 升级策略 - UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"` - // 静默策略 - SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"` - // 标签和注释 - Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"` - Annotations string `gorm:"type:text;comment:注释JSON(用于通知内容模板)" json:"annotations,omitempty"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` -} - -// TableName 指定表名 -func (AlertRuleV2) TableName() string { - return "monitor_alert_rules_v2" -} - -// AlertCondition 告警条件定义 -type AlertCondition struct { - Metric string `json:"metric"` // 指标名称 - Operator Operator `json:"operator"` // 比较运算符 - Threshold float64 `json:"threshold"` // 阈值 - Unit string `json:"unit"` // 单位 - Index string `json:"index"` // 实例索引 - LogicOp string `json:"logic_op"` // 逻辑关系: AND/OR - AggFunc AggFunction `json:"agg_func"` // 条件级聚合函数 - Window int `json:"window"` // 条件级聚合窗口 -} - -// TableName 指定表名 -func (AlertCondition) TableName() string { - return "monitor_alert_conditions" -} - -// AlertConditionGroup 告警条件组 -type AlertConditionGroup struct { - Conditions []AlertCondition `json:"conditions"` // 条件列表 - LogicOp string `json:"logic_op"` // 组间逻辑: AND/OR -} - -// RecoverConfig 告警恢复配置 -type RecoverConfig struct { - Count int `json:"count"` // 连续恢复次数 - Window int `json:"window"` // 恢复检查窗口(秒) -} - -// NotifyMode 通知模式 -type NotifyMode string - -const ( - NotifyModeEvery NotifyMode = "every" // 每次都通知 - NotifyModeOnce NotifyMode = "once" // 仅通知一次 - NotifyModeUpgrade NotifyMode = "upgrade" // 升级通知 -) - -// ============================================ -// AlertNotifyTemplate 告警通知模板 -// ============================================ - -// AlertNotifyTemplate 告警通知模板表 -// 定义告警通知的内容模板,支持变量替换 -type AlertNotifyTemplate struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"` - // 模板内容 - TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"` - BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"` - // 变量定义 - Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"` - // 恢复通知模板 - RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"` - RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"` - IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"` - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` -} - -// TableName 指定表名 -func (AlertNotifyTemplate) TableName() string { - return "monitor_alert_notify_templates" -} - -// TemplateVariable 模板变量定义 -type TemplateVariable struct { - Name string `json:"name"` // 变量名 - Description string `json:"description"` // 描述 - Example string `json:"example"` // 示例 -} - -// DefaultTemplateVariables 默认模板变量 -var DefaultTemplateVariables = []TemplateVariable{ - {Name: "{{.RuleName}}", Description: "规则名称", Example: "CPU使用率告警"}, - {Name: "{{.TargetName}}", Description: "目标名称", Example: "WebServer-01"}, - {Name: "{{.TargetIP}}", Description: "目标IP", Example: "192.168.1.10"}, - {Name: "{{.Severity}}", Description: "告警级别", Example: "critical"}, - {Name: "{{.MetricName}}", Description: "指标名称", Example: "cpu_usage"}, - {Name: "{{.Value}}", Description: "当前值", Example: "95.2"}, - {Name: "{{.Threshold}}", Description: "阈值", Example: "90"}, - {Name: "{{.Operator}}", Description: "运算符", Example: ">"}, - {Name: "{{.Time}}", Description: "触发时间", Example: "2024-01-15 10:30:00"}, - {Name: "{{.Duration}}", Description: "持续时间", Example: "5m"}, - {Name: "{{.Description}}", Description: "规则描述", Example: "CPU使用率超过阈值"}, - {Name: "{{.URL}}", Description: "详情链接", Example: "https://ezops.io/alerts/123"}, -} - -// ============================================ -// AlertUpgrade 告警升级策略 -// ============================================ - -// AlertUpgrade 告警升级策略表 -// 定义告警在不同阶段的升级策略 -type AlertUpgrade struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:策略名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - // 升级规则(JSON存储支持多级升级) - Rules string `gorm:"type:text;comment:升级规则JSON数组" json:"rules,omitempty"` - // 执行配置 - MaxLevel int `gorm:"default:3;comment:最大升级级别" json:"max_level"` - IsDefault bool `gorm:"default:false;comment:是否默认策略" json:"is_default"` - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` -} - -// TableName 指定表名 -func (AlertUpgrade) TableName() string { - return "monitor_alert_upgrades" -} - -// AlertUpgradeRule 告警升级规则 -type AlertUpgradeRule struct { - Level int `json:"level"` // 升级级别 - Duration int `json:"duration"` // 触发升级的持续时长(秒) - Severity string `json:"severity"` // 升级后的级别 - ChannelIDs []string `json:"channel_ids"` // 升级后通知渠道 - NotifyUsers []uint `json:"notify_users"` // 升级后通知用户 - RepeatInterval int `json:"repeat_interval"` // 重复通知间隔(秒) -} - -// AlertUpgradeRecord 告警升级记录 -type AlertUpgradeRecord struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - AlertID uint `gorm:"not null;index;comment:告警ID" json:"alert_id"` - RuleID uint `gorm:"not null;comment:规则ID" json:"rule_id"` - FromLevel int `gorm:"comment:原级别" json:"from_level"` - ToLevel int `gorm:"comment:升级后级别" json:"to_level"` - UpgradedAt time.Time `gorm:"not null;comment:升级时间" json:"upgraded_at"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` -} - -// TableName 指定表名 -func (AlertUpgradeRecord) TableName() string { - return "monitor_alert_upgrade_records" -} - -// ============================================ -// AlertSilence 告警静默规则 -// ============================================ - -// AlertSilenceType 静默类型 -type AlertSilenceType string - -const ( - AlertSilenceOneTime AlertSilenceType = "onetime" // 一次性静默 - AlertSilenceDaily AlertSilenceType = "daily" // 每日重复 - AlertSilenceWeekly AlertSilenceType = "weekly" // 每周重复 -) - -// AlertSilence 告警静默规则表 -// 定义告警静默策略,支持一次性、周期性静默 -type AlertSilence struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:静默规则名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - // 匹配条件 - RuleIDs string `gorm:"type:varchar(256);comment:匹配的告警规则ID列表JSON" json:"rule_ids,omitempty"` - TargetIDs string `gorm:"type:varchar(256);comment:匹配的目标ID列表JSON" json:"target_ids,omitempty"` - Matchers string `gorm:"type:text;comment:标签匹配条件JSON" json:"matchers,omitempty"` - // 时间配置 - SilenceType AlertSilenceType `gorm:"type:varchar(16);default:'onetime';comment:静默类型" json:"silence_type"` - StartTime time.Time `gorm:"not null;comment:开始时间" json:"start_time"` - EndTime time.Time `gorm:"not null;comment:结束时间" json:"end_time"` - Timezone string `gorm:"type:varchar(32);default:'Asia/Shanghai';comment:时区" json:"timezone"` - // 周期性配置 - Weekdays string `gorm:"type:varchar(64);comment:生效星期(0-6)JSON数组" json:"weekdays,omitempty"` - // 状态 - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` -} - -// TableName 指定表名 -func (AlertSilence) TableName() string { - return "monitor_alert_silences" -} - -// AlertSilenceMatcher 静默匹配条件 -type AlertSilenceMatcher struct { - Name string `json:"name"` // 标签名 - Value string `json:"value"` // 标签值 - IsRegex bool `json:"is_regex"` // 是否正则匹配 -} - -// AlertSilenceCheckResult 静默检查结果 -type AlertSilenceCheckResult struct { - IsSilenced bool `json:"is_silenced"` - SilenceID uint `json:"silence_id,omitempty"` - SilenceName string `json:"silence_name,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` -} - -// ============================================ -// AlertEventV2 增强告警事件 -// ============================================ - -// AlertEventV2Status 告警事件状态 -type AlertEventV2Status string - -const ( - AlertEventV2Firing AlertEventV2Status = "firing" // 触发中 - AlertEventV2Resolved AlertEventV2Status = "resolved" // 已恢复 - AlertEventV2Silenced AlertEventV2Status = "silenced" // 已静默 -) - -// AlertEventV2 增强告警事件表 -// 记录告警触发和恢复的全过程 -type AlertEventV2 struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"` - TargetID uint `gorm:"not null;index;comment:目标ID" json:"target_id"` - TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"` - TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"` - Severity AlertSeverity `gorm:"type:varchar(16);not null;index;comment:告警级别" json:"severity"` - Status AlertEventV2Status `gorm:"type:varchar(16);default:'firing';index;comment:事件状态" json:"status"` - Title string `gorm:"type:varchar(256);not null;comment:告警标题" json:"title"` - Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"` - // 触发值信息 - MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"` - Value float64 `gorm:"comment:触发值" json:"value,omitempty"` - Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"` - Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"` - Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"` - // 标签和注释 - Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"` - Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` - // 时间信息 - StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"` - ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"` - Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` - LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"` - NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count"` - LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"` - UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level"` - // 确认信息 - Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"` - AcknowledgedBy uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"` - AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"` - AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` -} - -// TableName 指定表名 -func (AlertEventV2) TableName() string { - return "monitor_alert_events_v2" -} - -// AlertRuleV2CreateRequest 告警规则创建请求 -type AlertRuleV2CreateRequest struct { - Name string `json:"name" binding:"required,max=128"` - Description string `json:"description"` - Severity AlertSeverity `json:"severity" binding:"required"` - TargetType string `json:"target_type"` - TargetID uint `json:"target_id"` - TargetTags string `json:"target_tags"` - Conditions []AlertCondition `json:"conditions" binding:"required,min=1"` - AggWindow int `json:"agg_window"` - AggFunction AggFunction `json:"agg_function"` - RecoverCount int `json:"recover_count"` - Interval int `json:"interval"` - CheckTimes int `json:"check_times"` - NotifyMode string `json:"notify_mode"` - ChannelIDs []string `json:"channel_ids"` - TemplateID uint `json:"template_id"` - UpgradeID uint `json:"upgrade_id"` - SilenceID uint `json:"silence_id"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` -} - -// AlertRuleV2UpdateRequest 告警规则更新请求 -type AlertRuleV2UpdateRequest struct { - Name string `json:"name" binding:"max=128"` - Description string `json:"description"` - Severity AlertSeverity `json:"severity"` - Status AlertRuleV2Status `json:"status"` - TargetType string `json:"target_type"` - TargetID uint `json:"target_id"` - TargetTags string `json:"target_tags"` - Conditions []AlertCondition `json:"conditions"` - AggWindow int `json:"agg_window"` - AggFunction AggFunction `json:"agg_function"` - RecoverCount int `json:"recover_count"` - Interval int `json:"interval"` - CheckTimes int `json:"check_times"` - NotifyMode string `json:"notify_mode"` - ChannelIDs []string `json:"channel_ids"` - TemplateID uint `json:"template_id"` - UpgradeID uint `json:"upgrade_id"` - SilenceID uint `json:"silence_id"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` -} diff --git a/backend/internal/models/cicd.go b/backend/internal/models/cicd.go index 204db2e..0e3a666 100644 --- a/backend/internal/models/cicd.go +++ b/backend/internal/models/cicd.go @@ -10,6 +10,7 @@ type Pipeline struct { ID uint `gorm:"primarykey" json:"id"` Name string `gorm:"size:128;not null" json:"name"` Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + JobName string `gorm:"size:128;uniqueIndex;comment:Job名称(webhook URL标识, 如 cex-admin-server)" json:"job_name,omitempty"` Description string `gorm:"size:500" json:"description"` Type string `gorm:"size:32;default:deploy" json:"type"` // build, deploy, test Source string `gorm:"size:255" json:"source"` // Git仓库地址 @@ -18,13 +19,51 @@ type Pipeline struct { Stages string `gorm:"type:text" json:"stages"` // JSON Variables string `gorm:"type:text" json:"variables"` // JSON Enabled bool `gorm:"default:true" json:"enabled"` - LastRunAt *time.Time `json:"last_run_at"` - LastStatus string `gorm:"size:32" json:"last_status"` - RunCount int `gorm:"default:0" json:"run_count"` - SuccessCount int `gorm:"default:0" json:"success_count"` - FailCount int `gorm:"default:0" json:"fail_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + // 部署模式 + DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"` + DeployTarget string `gorm:"size:16;default:ssh;comment:部署目标 ssh/k8s" json:"deploy_target"` + + // SSH 部署目标服务器 (JSON: [1,2,3]) + ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"` + + // K8s 部署目标 + K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters + K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"` + + // 仓库和镜像仓库关联(旧字段,待废弃) + RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos + RegistryID *uint `json:"registry_id"` // FK → deploy_pipeline_registries + + // 凭证关联(直接关联凭证 ID) + GitCredentialID *uint `json:"git_credential_id"` // FK → iam_credentials (Git Token) + RegistryCredentialID *uint `json:"registry_credential_id"` // FK → iam_credentials (Docker Password) + + // 环境分组 + EnvironmentID *uint `json:"environment_id"` // FK → deploy_environments + + // 触发配置 + TriggerMode string `gorm:"type:varchar(32);default:'manual';comment:触发方式: git_push/cron/webhook/manual" json:"trigger_mode"` + WatchBranches string `gorm:"type:varchar(256);comment:监听分支(逗号分隔, 支持通配)" json:"watch_branches,omitempty"` + ExcludeBranch string `gorm:"type:varchar(256);comment:排除分支" json:"exclude_branch,omitempty"` + CronExpr string `gorm:"type:varchar(64);comment:Cron 表达式" json:"cron_expr,omitempty"` + WebhookToken string `gorm:"type:varchar(128);comment:Webhook 签名 Token" json:"webhook_token,omitempty"` + + // 缓存配置 + CachePaths string `gorm:"type:text;comment:缓存路径(逗号分隔)" json:"cache_paths,omitempty"` + + LastRunAt *time.Time `json:"last_run_at"` + LastStatus string `gorm:"size:32" json:"last_status"` + RunCount int `gorm:"default:0" json:"run_count"` + SuccessCount int `gorm:"default:0" json:"success_count"` + FailCount int `gorm:"default:0" json:"fail_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 关联 + Repo *PipelineRepo `json:"repo,omitempty" gorm:"foreignKey:RepoID"` + Registry *PipelineRegistry `json:"registry,omitempty" gorm:"foreignKey:RegistryID"` + Environment *DeployEnvironment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"` } // TableName 表名 @@ -44,6 +83,7 @@ type PipelineRun struct { Author string `gorm:"size:64" json:"author"` ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址 + WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径 Duration int `json:"duration"` // 秒 Stages string `gorm:"type:text" json:"stages"` // JSON Logs string `gorm:"type:longtext" json:"logs"` @@ -98,6 +138,7 @@ func (AppRelease) TableName() string { type CreatePipelineRequest struct { Name string `json:"name" binding:"required"` Code string `json:"code" binding:"required"` + JobName string `json:"job_name"` // Webhook Job名称 Description string `json:"description"` Type string `json:"type" binding:"omitempty,oneof=build deploy test"` Source string `json:"source"` @@ -105,11 +146,69 @@ type CreatePipelineRequest struct { Dockerfile string `json:"dockerfile"` Stages string `json:"stages"` Variables string `json:"variables"` + + // 部署模式 + DeployMode string `json:"deploy_mode"` // branch / tag + DeployTarget string `json:"deploy_target"` // ssh / k8s + ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3] + K8sClusterID *uint `json:"k8s_cluster_id"` + K8sNamespace string `json:"k8s_namespace"` + + RepoID *uint `json:"repo_id"` + RegistryID *uint `json:"registry_id"` + // 凭证关联 + GitCredentialID *uint `json:"git_credential_id"` + RegistryCredentialID *uint `json:"registry_credential_id"` + EnvironmentID *uint `json:"environment_id"` + + // 触发配置 + TriggerMode string `json:"trigger_mode"` + WatchBranches string `json:"watch_branches"` + ExcludeBranch string `json:"exclude_branch"` + CronExpr string `json:"cron_expr"` + WebhookToken string `json:"webhook_token"` + + // 缓存路径 + CachePaths string `json:"cache_paths"` +} + +// UpdatePipelineRequest 更新流水线请求 +type UpdatePipelineRequest struct { + Name string `json:"name"` + JobName string `json:"job_name"` + Description string `json:"description"` + Type string `json:"type"` + Source string `json:"source"` + Branch string `json:"branch"` + Dockerfile string `json:"dockerfile"` + Stages string `json:"stages"` + Variables string `json:"variables"` + DeployMode string `json:"deploy_mode"` + DeployTarget string `json:"deploy_target"` + ServerIDs string `json:"server_ids"` + K8sClusterID *uint `json:"k8s_cluster_id"` + K8sNamespace string `json:"k8s_namespace"` + RepoID *uint `json:"repo_id"` + RegistryID *uint `json:"registry_id"` + // 凭证关联 + GitCredentialID *uint `json:"git_credential_id"` + RegistryCredentialID *uint `json:"registry_credential_id"` + EnvironmentID *uint `json:"environment_id"` + // 触发配置 + TriggerMode string `json:"trigger_mode"` + WatchBranches string `json:"watch_branches"` + ExcludeBranch string `json:"exclude_branch"` + CronExpr string `json:"cron_expr"` + WebhookToken string `json:"webhook_token"` + // 缓存 + CachePaths string `json:"cache_paths"` + Enabled *bool `json:"enabled"` } // TriggerPipelineRequest 触发流水线请求 type TriggerPipelineRequest struct { - Branch string `json:"branch"` + Branch string `json:"branch"` + Tag string `json:"tag"` // 发布 Tag Variables map[string]string `json:"variables"` } diff --git a/backend/internal/models/credential.go b/backend/internal/models/credential.go index 393b851..a296563 100644 --- a/backend/internal/models/credential.go +++ b/backend/internal/models/credential.go @@ -4,44 +4,198 @@ import ( "time" ) -// Credential 凭据管理模型(SSH密钥/API Token/密码等统一管理) +// Credential 凭据管理模型 — 统一管理 SSH/Docker/Token/AKSK/数据库/API 等敏感信息 +// 设计参考:opsx-web Secret 模型,按类型动态切换字段 +// 核心加密字段 content 存储类型特有数据(JSON),API 不返回 +// +// 类型与内容 JSON 映射: +// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22} +// docker → {"server":"...","username":"...","password":"..."} +// tls → {"cert":"...","key":"...","ca":"..."} +// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."} +// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."} +// api → {"key":"...","endpoint":"..."} +// generic → {"key":"...","value":"..."} type Credential struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` // 凭据名称 - Type string `gorm:"size:32;not null" json:"type"` // ssh_key, api_token, password, access_key - Content string `gorm:"type:text;not null" json:"-"` // AES加密后的内容,API不返回 - Description string `gorm:"size:500" json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` // 凭据名称 + VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD + SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/docker/tls/aksk/database/api/generic + Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示) + Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回 + Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"] + Description string `gorm:"size:500" json:"description,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间 + IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用 + EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Credential) TableName() string { return "iam_credentials" } -// CredentialCreateRequest 创建凭据请求 -type CredentialCreateRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"` - Content string `json:"content" binding:"required"` // 明文,后端加密存储 - Description string `json:"description"` +// ────── 类型定义 ────── + +// SecretType SSH 凭据 content +type SSHSecretContent struct { + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` + SSHUser string `json:"ssh_user"` + SSHPort int `json:"ssh_port"` + Password string `json:"password,omitempty"` } -// CredentialUpdateRequest 更新凭据请求 -type CredentialUpdateRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"` - Content string `json:"content"` // 空则不更新内容 - Description string `json:"description"` +// DockerSecretContent Docker 凭据 content +type DockerSecretContent struct { + Server string `json:"server"` + Username string `json:"username"` + Password string `json:"password"` } -// CredentialListResponse 列表返回(内容脱敏) -type CredentialListResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Preview string `json:"preview"` // 内容预览(前8+后4字符) - Description string `json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// TLSSecretContent TLS 证书凭据 content +type TLSSecretContent struct { + Cert string `json:"cert"` + Key string `json:"key"` + CA string `json:"ca"` +} + +// AKSKSecretContent AKSK 凭据 content +type AKSKSecretContent struct { + Provider string `json:"provider"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Region string `json:"region,omitempty"` +} + +// DatabaseSecretContent 数据库凭据 content +type DatabaseSecretContent struct { + DBType string `json:"db_type"` + Host string `json:"host"` + Port int `json:"port"` + DBName string `json:"db_name"` + Username string `json:"username"` + Password string `json:"password"` +} + +// APISecretContent API 密钥凭据 content +type APISecretContent struct { + Key string `json:"api_key"` + Endpoint string `json:"endpoint,omitempty"` +} + +// GenericSecretContent 通用凭据 content +type GenericSecretContent struct { + Key string `json:"generic_key"` + Value string `json:"generic_value"` +} + +// ────── 请求/响应 ────── + +// CreateSecretRequest 创建凭据请求 +type CreateSecretRequest struct { + Name string `json:"name" binding:"required"` + VariableName string `json:"variable_name"` + SecretType string `json:"secret_type" binding:"required,oneof=ssh docker tls aksk database api generic"` + Tags []string `json:"tags"` + Description string `json:"description"` + ExpiresAt *time.Time `json:"expires_at"` + IsActive *bool `json:"is_active"` + + // 类型特有字段(明文,后端组装成 JSON 加密存储) + // SSH + SSHPrivateKey string `json:"ssh_private_key,omitempty"` + SSHPublicKey string `json:"ssh_public_key,omitempty"` + SSHUser string `json:"ssh_user,omitempty"` + SSHPort int `json:"ssh_port,omitempty"` + SSHPassword string `json:"ssh_password,omitempty"` + // Docker + DockerServer string `json:"docker_server,omitempty"` + DockerUsername string `json:"docker_username,omitempty"` + DockerPassword string `json:"docker_password,omitempty"` + // TLS + TLSCert string `json:"tls_cert,omitempty"` + TLSKey string `json:"tls_key,omitempty"` + TLSCA string `json:"tls_ca,omitempty"` + // AKSK + AKSKProvider string `json:"aksk_provider,omitempty"` + AKSKAccessKey string `json:"aksk_access_key,omitempty"` + AKSKSecretKey string `json:"aksk_secret_key,omitempty"` + AKSKRegion string `json:"aksk_region,omitempty"` + // Database + DBType string `json:"db_type,omitempty"` + DBHost string `json:"db_host,omitempty"` + DBPort int `json:"db_port,omitempty"` + DBName string `json:"db_name,omitempty"` + DBUsername string `json:"db_username,omitempty"` + DBPassword string `json:"db_password,omitempty"` + // API + APIKey string `json:"api_key,omitempty"` + APIEndpoint string `json:"api_endpoint,omitempty"` + // Generic + GenericKey string `json:"generic_key,omitempty"` + GenericValue string `json:"generic_value,omitempty"` +} + +// UpdateSecretRequest 更新凭据请求 +type UpdateSecretRequest struct { + Name *string `json:"name"` + VariableName *string `json:"variable_name"` + Tags []string `json:"tags"` + Description *string `json:"description"` + ExpiresAt *time.Time `json:"expires_at"` + IsActive *bool `json:"is_active"` + + // 类型特有字段 — 传了才更新 + SSHPrivateKey *string `json:"ssh_private_key,omitempty"` + SSHPublicKey *string `json:"ssh_public_key,omitempty"` + SSHUser *string `json:"ssh_user,omitempty"` + SSHPort *int `json:"ssh_port,omitempty"` + SSHPassword *string `json:"ssh_password,omitempty"` + DockerServer *string `json:"docker_server,omitempty"` + DockerUsername *string `json:"docker_username,omitempty"` + DockerPassword *string `json:"docker_password,omitempty"` + TLSCert *string `json:"tls_cert,omitempty"` + TLSKey *string `json:"tls_key,omitempty"` + TLSCA *string `json:"tls_ca,omitempty"` + AKSKProvider *string `json:"aksk_provider,omitempty"` + AKSKAccessKey *string `json:"aksk_access_key,omitempty"` + AKSKSecretKey *string `json:"aksk_secret_key,omitempty"` + AKSKRegion *string `json:"aksk_region,omitempty"` + DBType *string `json:"db_type,omitempty"` + DBHost *string `json:"db_host,omitempty"` + DBPort *int `json:"db_port,omitempty"` + DBName *string `json:"db_name,omitempty"` + DBUsername *string `json:"db_username,omitempty"` + DBPassword *string `json:"db_password,omitempty"` + APIKey *string `json:"api_key,omitempty"` + APIEndpoint *string `json:"api_endpoint,omitempty"` + GenericKey *string `json:"generic_key,omitempty"` + GenericValue *string `json:"generic_value,omitempty"` +} + +// SecretListResponse 列表返回(内容脱敏) +type SecretListResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + VariableName string `json:"variable_name,omitempty"` + SecretType string `json:"secret_type"` + Summary string `json:"summary,omitempty"` + Tags []string `json:"tags,omitempty"` + Description string `json:"description,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + IsActive bool `json:"is_active"` + EnvPrefix string `json:"env_prefix,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SecretListQuery 列表查询参数 +type SecretListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + SecretType string `form:"secret_type"` + Tags string `form:"tags"` // 逗号分隔 + Search string `form:"search"` } diff --git a/backend/internal/models/deploy.go b/backend/internal/models/deploy.go new file mode 100644 index 0000000..87d6c0c --- /dev/null +++ b/backend/internal/models/deploy.go @@ -0,0 +1,193 @@ +package models + +import "time" + +// ============================================ +// PipelineRepo Git 仓库认证配置 +// ============================================ + +// RepoAuthType 仓库认证方式 +type RepoAuthType string + +const ( + RepoAuthToken RepoAuthType = "token" // Personal Access Token + RepoAuthSSH RepoAuthType = "ssh" // SSH Key + RepoAuthPassword RepoAuthType = "password" // 用户名+密码 +) + +// PipelineRepo Git 仓库认证配置表 +type PipelineRepo struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + URL string `gorm:"type:varchar(512);not null;comment:仓库 URL" json:"url"` + AuthType RepoAuthType `gorm:"type:varchar(16);default:'token';comment:认证方式: token/ssh/password" json:"auth_type"` + CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"` + Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` + CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` +} + +// TableName 表名 +func (PipelineRepo) TableName() string { + return "deploy_pipeline_repos" +} + +// ============================================ +// PipelineRegistry 镜像仓库认证配置 +// ============================================ + +// RegistryAuthType 镜像仓库认证方式 +type RegistryAuthType string + +const ( + RegistryAuthToken RegistryAuthType = "token" // Access Token + RegistryAuthPassword RegistryAuthType = "password" // 用户名+密码 +) + +// PipelineRegistry 镜像仓库认证配置表 +type PipelineRegistry struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + RegistryURL string `gorm:"type:varchar(512);not null;comment:镜像仓库地址" json:"registry_url"` + Namespace string `gorm:"type:varchar(128);comment:默认命名空间" json:"namespace,omitempty"` + AuthType RegistryAuthType `gorm:"type:varchar(16);default:'password';comment:认证方式: token/password" json:"auth_type"` + CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"` + Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` + CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` +} + +// TableName 表名 +func (PipelineRegistry) TableName() string { + return "deploy_pipeline_registries" +} + +// ============================================ +// PipelineStage 流水线阶段定义(JSON) +// ============================================ + +// PipelineStage 一个阶段包含多个任务 +type PipelineStage struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // source/build/deploy/test/custom + Tasks []PipelineTask `json:"tasks"` +} + +// PipelineTask 阶段内的任务 +type PipelineTask struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // build/deploy/test/custom + Icon string `json:"icon,omitempty"` + Config map[string]string `json:"config,omitempty"` + Script string `json:"script,omitempty"` + ScriptType string `json:"script_type,omitempty"` // shell/python +} + +// ============================================ +// DeployEnvironment 发布环境分组 +// ============================================ + +type EnvironmentType string + +const ( + EnvProduction EnvironmentType = "production" + EnvStaging EnvironmentType = "staging" + EnvTesting EnvironmentType = "testing" + EnvDev EnvironmentType = "development" +) + +// DeployEnvironment 环境分组表(生产/测试/预发布/开发) +type DeployEnvironment struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(64);not null;uniqueIndex;comment:环境名称" json:"name"` + DisplayName string `gorm:"type:varchar(128);not null;comment:显示名称" json:"display_name"` + Type EnvironmentType `gorm:"type:varchar(16);not null;index;comment:环境类型: production/staging/testing/development" json:"type"` + Color string `gorm:"type:varchar(16);default:'#3B82F6';comment:标识颜色" json:"color"` + SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` +} + +func (DeployEnvironment) TableName() string { + return "deploy_environments" +} + +// ============================================ +// DeployPipelineMember 流水线成员 +// ============================================ + +type MemberRole string + +const ( + MemberOwner MemberRole = "owner" // 拥有者 + MemberEditor MemberRole = "editor" // 编辑者 + MemberViewer MemberRole = "viewer" // 查看者 +) + +// DeployPipelineMember 流水线成员表 +// 支持两种授权方式:直接用户授权(username 非空)和用户组授权(group_id 非空) +type DeployPipelineMember struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + PipelineID uint `gorm:"index:idx_pipeline_user,unique,priority:1;index:idx_pipeline_group,unique,priority:1;not null;comment:流水线ID" json:"pipeline_id"` + Username *string `gorm:"type:varchar(64);index:idx_pipeline_user,unique,priority:2;comment:用户名(直接授权)" json:"username,omitempty"` + GroupID *uint `gorm:"index:idx_pipeline_group,unique,priority:2;comment:用户组ID(组授权)" json:"group_id,omitempty"` + Role MemberRole `gorm:"type:varchar(16);default:'editor';comment:角色: owner/editor/viewer" json:"role"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + + // 关联 + Pipeline *Pipeline `json:"pipeline,omitempty" gorm:"foreignKey:PipelineID"` + Group *DeployUserGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"` +} + +func (DeployPipelineMember) TableName() string { + return "deploy_pipeline_members" +} + +// ============================================ +// DeployUserGroup 发布系统用户组 +// ============================================ + +// DeployUserGroup 用户组(用于流水线权限分组授权) +type DeployUserGroup struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(64);uniqueIndex;not null;comment:组名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + + // 关联 + Members []DeployUserGroupMember `json:"members,omitempty" gorm:"foreignKey:GroupID"` +} + +func (DeployUserGroup) TableName() string { + return "deploy_user_groups" +} + +// ============================================ +// DeployUserGroupMember 用户组成员 +// ============================================ + +// DeployUserGroupMember 用户组成员关联表 +type DeployUserGroupMember struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + GroupID uint `gorm:"uniqueIndex:idx_group_user;not null;comment:组ID" json:"group_id"` + Username string `gorm:"type:varchar(64);uniqueIndex:idx_group_user;not null;comment:用户名" json:"username"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + + // 关联 + Group *DeployUserGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"` +} + +func (DeployUserGroupMember) TableName() string { + return "deploy_user_group_members" +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 420ef89..6979aac 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -69,6 +69,40 @@ func (Permission) TableName() string { return "iam_permissions" } +// ItemRole Jenkins-style Item Role — regex pipeline code matching +type ItemRole struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Pattern string `gorm:"size:255;not null;comment:正则表达式匹配流水线code" json:"pattern"` + Role string `gorm:"size:16;default:'editor';comment:角色owner/editor/viewer" json:"role"` + Description string `gorm:"size:255" json:"description"` + Status int `gorm:"default:1;comment:0禁用1启用" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 表名 +func (ItemRole) TableName() string { + return "iam_item_roles" +} + +// UserItemRole user ↔ item_role association +type UserItemRole struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_item_role;not null" json:"user_id"` + ItemRoleID uint `gorm:"uniqueIndex:idx_user_item_role;not null" json:"item_role_id"` + CreatedAt time.Time `json:"created_at"` + + User *User `json:"user,omitempty" gorm:"foreignKey:UserID"` + ItemRole *ItemRole `json:"item_role,omitempty" gorm:"foreignKey:ItemRoleID"` +} + +// TableName 表名 +func (UserItemRole) TableName() string { + return "iam_user_item_roles" +} + // LoginRequest 登录请求 type LoginRequest struct { Username string `json:"username" binding:"required"` @@ -111,11 +145,36 @@ type CreateRoleRequest struct { // UpdateRoleRequest 更新角色请求 // Logan.Li type UpdateRoleRequest struct { - Name string `json:"name" binding:"omitempty,min=1,max=64"` - Code string `json:"code" binding:"omitempty,min=1,max=64"` + Name string `json:"name" binding:"omitempty,min=1,max=64"` + Code string `json:"code" binding:"omitempty,min=1,max=64"` Description *string `json:"description"` - Status *int `json:"status"` - PermissionIDs []uint `json:"permission_ids"` + Status *int `json:"status"` + PermissionIDs []uint `json:"permission_ids"` +} + +// CreateItemRoleRequest 创建Item Role请求 +type CreateItemRoleRequest struct { + Name string `json:"name" binding:"required,min=1,max=64"` + Code string `json:"code" binding:"required,min=1,max=64"` + Pattern string `json:"pattern" binding:"required,max=255"` + Role string `json:"role" binding:"oneof=owner editor viewer"` + Description string `json:"description"` +} + +// UpdateItemRoleRequest 更新Item Role请求 +type UpdateItemRoleRequest struct { + Name string `json:"name" binding:"omitempty,min=1,max=64"` + Code string `json:"code" binding:"omitempty,min=1,max=64"` + Pattern string `json:"pattern" binding:"omitempty,max=255"` + Role string `json:"role" binding:"omitempty,oneof=owner editor viewer"` + Description string `json:"description"` + Status *int `json:"status"` +} + +// AssignItemRoleRequest 分配Item Role给用户 +type AssignItemRoleRequest struct { + UserID uint `json:"user_id" binding:"required"` + ItemRoleID uint `json:"item_role_id" binding:"required"` } // ChangePasswordRequest 修改密码请求 diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index ba21ba7..05ca767 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -119,6 +119,19 @@ func AutoMigrate(db *gorm.DB) error { &models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li &models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li &models.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li + &models.PipelineRepo{}, // Git 仓库认证 // Logan.Li + &models.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li + &models.DeployEnvironment{}, // 发布环境分组 // Logan.Li + &models.DeployPipelineMember{}, // 流水线成员 // Logan.Li + &models.DeployUserGroup{}, // 用户组 // Logan.Li + &models.DeployUserGroupMember{}, // 用户组成员 // Logan.Li + &models.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li + &models.UserItemRole{}, // User ↔ ItemRole association // Logan.Li + &models.AlertCondition{}, // 告警条件 // Logan.Li + &models.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li + &models.AlertUpgrade{}, // 告警升级策略 // Logan.Li + &models.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li + &models.AlertSilence{}, // 告警静默 // Logan.Li } var lastErr error diff --git a/backend/pkg/redis/go_redis.go b/backend/pkg/redis/go_redis.go index 6834e4e..eb7ea86 100644 --- a/backend/pkg/redis/go_redis.go +++ b/backend/pkg/redis/go_redis.go @@ -93,3 +93,7 @@ func (r *goRedisCache) ZRangeByScore(ctx context.Context, key string, min, max s func (r *goRedisCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error { return r.client.ZRemRangeByScore(ctx, key, min, max).Err() } + +func (r *goRedisCache) Publish(ctx context.Context, channel string, message string) error { + return r.client.Publish(ctx, channel, message).Err() +} diff --git a/backend/pkg/redis/redis.go b/backend/pkg/redis/redis.go index e947b21..e313354 100644 --- a/backend/pkg/redis/redis.go +++ b/backend/pkg/redis/redis.go @@ -50,6 +50,9 @@ type Cache interface { Ping(ctx context.Context) error Close() error + // Pub/Sub(实时日志推送) + Publish(ctx context.Context, channel string, message string) error + // 有序集合操作(热数据指标存储,仅 go-redis 后端完整支持) ZAdd(ctx context.Context, key string, member SortedSetMember) error ZRangeByScore(ctx context.Context, key string, min, max string) ([]SortedSetMember, error) diff --git a/backend/pkg/redis/redka.go b/backend/pkg/redis/redka.go index 88f8ddc..2ba4d12 100644 --- a/backend/pkg/redis/redka.go +++ b/backend/pkg/redis/redka.go @@ -111,3 +111,9 @@ func (r *redkaCache) ZRangeByScore(ctx context.Context, key string, min, max str func (r *redkaCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error { return errSortedSetNotSupported } + +var errPubSubNotSupported = fmt.Errorf("Pub/Sub is not supported by the redka backend; use go-redis (driver=redis)") + +func (r *redkaCache) Publish(ctx context.Context, channel string, message string) error { + return errPubSubNotSupported +} diff --git a/backend/server b/backend/server new file mode 100755 index 0000000..c7afeac Binary files /dev/null and b/backend/server differ diff --git a/docker-compose.yml b/docker-compose.yml index 1aff1d3..76d710a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: - "8080:8080" volumes: - ./backend/config.yaml:/app/config.yaml + - /var/run/docker.sock:/var/run/docker.sock depends_on: mysql: condition: service_healthy diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2faa8c0..559c747 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,13 +24,16 @@ import PodMetricsPage from '@/components/kubernetes/PodMetricsPage'; import EventsPage from '@/components/kubernetes/EventsPage'; import CapacityPage from '@/components/kubernetes/CapacityPage'; import { AlertManager } from '@/components/monitor/AlertManager'; -import { AppReleaseManager } from '@/components/deploy/AppRelease'; import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow'; import { ScriptManager } from '@/components/automation/ScriptManager'; import { SystemManager } from '@/components/system/SystemManager'; -import { CredentialManager } from '@/components/system/CredentialManager'; +import CredentialManager from '@/components/deploy/CredentialManager'; import { PipelineManager } from '@/components/deploy/PipelineManager'; -import { ReleaseManager } from '@/components/deploy/ReleaseManager'; +import DeployCredentials from '@/components/deploy/DeployCredentials'; +import PipelineEditor from '@/components/deploy/PipelineEditor'; +import PipelineRunDetail from '@/components/deploy/PipelineRunDetail'; +import DeployRBAC from '@/components/deploy/DeployRBAC'; +import UserGroupsManager from '@/components/deploy/UserGroupsManager'; import { CronJobManager } from '@/components/automation/CronJobManager'; import AnsibleManager from '@/components/automation/AnsibleManager'; import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow'; @@ -136,8 +139,12 @@ function App() { } /> {/* 应用发布 */} } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 监控告警 */} } /> } /> diff --git a/frontend/src/components/deploy/CredentialFormDialog.tsx b/frontend/src/components/deploy/CredentialFormDialog.tsx new file mode 100644 index 0000000..175c102 --- /dev/null +++ b/frontend/src/components/deploy/CredentialFormDialog.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Plus, Key, Globe, Container } from 'lucide-react'; +import { toast } from 'sonner'; +import { deployApi } from '@/services/api'; +import http from '@/services/http'; + +interface Repo { + id: number; name: string; url: string; credential_id: number | null; +} +interface Registry { + id: number; name: string; registry_url: string; namespace: string; credential_id: number | null; +} +interface Credential { + id: number; name: string; secret_type: string; summary: string; +} + +interface Props { + open: boolean; + onOpenChange: (v: boolean) => void; + type: 'repo' | 'registry'; + editId: number | null; + repos: Repo[]; + registries: Registry[]; + onSave: () => void; +} + +export default function CredentialFormDialog({ open, onOpenChange, type, editId, repos, registries, onSave }: Props) { + const isRepo = type === 'repo'; + const [name, setName] = useState(''); + const [desc, setDesc] = useState(''); + const [url, setUrl] = useState(''); + const [namespace, setNamespace] = useState(''); + const [credentialId, setCredentialId] = useState(''); + const [credentials, setCredentials] = useState([]); + const [saving, setSaving] = useState(false); + + // Docker 仓库 → docker 类型;Git 仓库 → api 或 docker 类型 + const filteredCredentials = credentials.filter(c => + isRepo ? true : c.secret_type === 'docker' + ); + + const loadCredentials = async () => { + try { + const res: any = await http.get('/credentials?page_size=100'); + setCredentials(res?.list || []); + } catch { /* silent */ } + }; + + useEffect(() => { + loadCredentials(); + if (editId) { + if (isRepo) { + const r = repos.find(x => x.id === editId); + if (r) { setName(r.name); setDesc(''); setUrl(r.url); setCredentialId(r.credential_id || ''); } + } else { + const r = registries.find(x => x.id === editId); + if (r) { setName(r.name); setDesc(''); setUrl(r.registry_url); setNamespace(r.namespace || ''); setCredentialId(r.credential_id || ''); } + } + } else { + setName(''); setDesc(''); setUrl(''); setNamespace(''); setCredentialId(''); + } + }, [editId, open, isRepo]); + + const handleSave = async () => { + if (!name.trim() || !url.trim()) { toast.error('请填写名称和地址'); return; } + setSaving(true); + try { + const cid = credentialId ? Number(credentialId) : null; + const payload = isRepo + ? { name: name.trim(), description: desc.trim(), url: url.trim(), credential_id: cid } + : { name: name.trim(), description: desc.trim(), registry_url: url.trim(), namespace: namespace.trim(), credential_id: cid }; + if (editId) { + if (isRepo) await deployApi.updateRepo(editId, payload); + else await deployApi.updateRegistry(editId, payload); + toast.success('已更新'); + } else { + if (isRepo) await deployApi.createRepo(payload); + else await deployApi.createRegistry(payload); + toast.success('已创建'); + } + onOpenChange(false); + onSave(); + } catch { toast.error('保存失败'); } + finally { setSaving(false); } + }; + + const CredIcon = isRepo ? Globe : Container; + + return ( + + + + + + {editId ? '编辑' : '新增'}{isRepo ? ' Git 仓库' : ' 镜像仓库'} + + +
+
+ setName(e.target.value)} placeholder={isRepo ? '如:GitLab CE' : '如:Harbor'} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
+
+ setUrl(e.target.value)} placeholder={isRepo ? 'http://10.10.10.72:9000/group/repo.git' : 'http://10.10.10.72'} className="font-mono text-sm bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
+ {!isRepo &&
+ setNamespace(e.target.value)} placeholder="如:aiops" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
} + + {/* 关联凭证 */} +
+ + + {filteredCredentials.length === 0 && ( +

+ + 暂无{isRepo ? '' : 'Docker 密码'}类型凭证,请在凭证配置页面新增 +

+ )} +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/deploy/CredentialManager.tsx b/frontend/src/components/deploy/CredentialManager.tsx new file mode 100644 index 0000000..8ffa0a8 --- /dev/null +++ b/frontend/src/components/deploy/CredentialManager.tsx @@ -0,0 +1,822 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Plus, Pencil, Trash2, RefreshCw, Key, Shield, Globe, Search, + Lock, Eye, EyeOff, Copy, Server, Container, Database, FileText, + Cloud, Tag, Clock, AlertTriangle, CheckCircle2, XCircle, Zap, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; + +import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import http from '@/services/http'; + +// ────── Types ────── + +interface Credential { + id: number; + name: string; + variable_name: string; + secret_type: SecretType; + summary: string; + tags: string[]; + description: string; + expires_at: string | null; + is_active: boolean; + env_prefix: string; + created_at: string; + updated_at: string; +} + +type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic'; + +const TYPE_CONFIG: Record = { + ssh: { label: 'SSH 密钥', icon: Server, color: 'text-amber-400', desc: 'SSH 私钥/公钥/密码' }, + docker: { label: 'Docker 凭证', icon: Container, color: 'text-cyan-400', desc: 'Docker Registry 登录凭据' }, + tls: { label: 'TLS 证书', icon: Shield, color: 'text-green-400', desc: 'TLS/SSL 证书与私钥' }, + aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'text-purple-400', desc: '云服务 AccessKey/SecretKey' }, + database: { label: '数据库凭证', icon: Database, color: 'text-blue-400', desc: '数据库连接信息' }, + api: { label: 'API 密钥', icon: Globe, color: 'text-orange-400', desc: 'API Token/Key + 端点' }, + generic: { label: '通用凭证', icon: FileText, color: 'text-gray-400', desc: '自定义 Key/Value 键值对' }, +}; + +const TYPE_BADGE_CLASS: Record = { + ssh: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + docker: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30', + tls: 'bg-green-500/20 text-green-400 border-green-500/30', + aksk: 'bg-purple-500/20 text-purple-400 border-purple-500/30', + database: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + api: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + generic: 'bg-gray-500/20 text-gray-400 border-gray-500/30', +}; + +// ────── Detail Dialog ────── + +function CredDetailDialog({ open, onClose, credId }: { open: boolean; onClose: () => void; credId: number | null }) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [showMap, setShowMap] = useState>({}); + + useEffect(() => { + if (!credId || !open) return; + setLoading(true); + http.get(`/credentials/${credId}`).then((res: any) => { + setDetail(res); + }).catch(() => toast.error('加载详情失败')).finally(() => setLoading(false)); + }, [credId, open]); + + if (!open || !credId) return null; + + const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] })); + const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success('已复制'); }; + + const st = detail?.secret_type as SecretType; + const cfg = TYPE_CONFIG[st]; + const content = detail?.content || {}; + + const Field = ({ label, value, sensitive, fieldKey }: { label: string; value: any; sensitive?: boolean; fieldKey?: string }) => { + if (value === undefined || value === null || value === '') return null; + const isLong = typeof value === 'string' && value.length > 80; + const show = fieldKey ? showMap[fieldKey] : false; + return ( +
+
+ {label} +
+
+ {isLong ? ( +