feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化
- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理 - 正则匹配流水线 code,实时命中预览 - 内置用户分配面板,无需跳转系统管理 - 支持创建/编辑/删除/启用禁用 Item Role - 修复 PipelineEditor 成员管理 http 双解包 bug - 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns) - 新增批量删除流水线运行记录 API - Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组 - 恢复系统管理菜单(不受发布权限改动影响) - 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
1 parent
0fea0ccc20
commit
24f955832e
40 files changed
+7530
-1032
No files matched your search
+14
-3
@@ -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 '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>central</mirrorOf><name>Aliyun Maven</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /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"]
|
||||
@@ -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 "$@"
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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: "已删除"})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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: "已授权"})
|
||||
}
|
||||
@@ -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: "移除成功"})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 修改密码请求
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Reference in new issue
Block a user