diff --git a/pkg/cli/initconfig/cmd/init.go b/pkg/cli/initconfig/cmd/init.go index 8ab70ad404..ad8296e7ca 100644 --- a/pkg/cli/initconfig/cmd/init.go +++ b/pkg/cli/initconfig/cmd/init.go @@ -187,6 +187,7 @@ func createOrUpdateMongodbIndex(ctx context.Context) { commonrepo.NewLLMIntegrationColl(), commonrepo.NewReleasePlanColl(), commonrepo.NewReleasePlanLogColl(), + commonrepo.NewReleasePlanVersionColl(), commonrepo.NewEnvServiceVersionColl(), commonrepo.NewLabelColl(), commonrepo.NewSprintTemplateColl(), diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index 7cdc25ed7f..9e8003856c 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -25,6 +25,7 @@ import ( type ReleasePlan struct { ID primitive.ObjectID `bson:"_id,omitempty" yaml:"-" json:"id"` Index int64 `bson:"index" yaml:"index" json:"index"` + Version int64 `bson:"version" yaml:"version" json:"version"` Name string `bson:"name" yaml:"name" json:"name"` Manager string `bson:"manager" yaml:"manager" json:"manager"` // ManagerID is the user id of the manager @@ -130,9 +131,28 @@ type ReleasePlanLog struct { Before interface{} `bson:"before" json:"before"` After interface{} `bson:"after" json:"after"` Detail string `bson:"detail" json:"detail"` + Version int64 `bson:"version,omitempty" json:"version,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanLog) TableName() string { return "release_plan_log" } + +type ReleasePlanVersion struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + Version int64 `bson:"version" json:"version"` + Operator string `bson:"operator" json:"operator"` + Account string `bson:"account" json:"account"` + SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"` + SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` + Verb string `bson:"verb,omitempty" json:"verb,omitempty"` + BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` + Snapshot interface{} `bson:"snapshot" json:"snapshot"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +func (ReleasePlanVersion) TableName() string { + return "release_plan_version" +} diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go index 6c4bb0d328..cf9c37cc7f 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go @@ -79,6 +79,10 @@ func (c *ReleasePlanColl) EnsureIndex(ctx context.Context) error { Keys: bson.M{"update_time": 1}, Options: options.Index().SetUnique(false), }, + { + Keys: bson.M{"version": 1}, + Options: options.Index().SetUnique(false), + }, } _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) @@ -121,6 +125,35 @@ func (c *ReleasePlanColl) UpdateByID(ctx context.Context, idString string, args return err } +func (c *ReleasePlanColl) UpdateVersionByID(ctx context.Context, idString string, version int64) error { + id, err := primitive.ObjectIDFromHex(idString) + if err != nil { + return fmt.Errorf("invalid id") + } + + query := bson.M{"_id": id} + change := bson.M{"$set": bson.M{"version": version}} + _, err = c.UpdateOne(ctx, query, change) + return err +} + +func (c *ReleasePlanColl) IncrementVersionByID(ctx context.Context, idString string) (int64, error) { + id, err := primitive.ObjectIDFromHex(idString) + if err != nil { + return 0, fmt.Errorf("invalid id") + } + + query := bson.M{"_id": id} + change := bson.M{"$inc": bson.M{"version": 1}} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + + result := new(models.ReleasePlan) + if err := c.FindOneAndUpdate(ctx, query, change, opts).Decode(result); err != nil { + return 0, err + } + return result.Version, nil +} + func (c *ReleasePlanColl) DeleteByID(ctx context.Context, idString string) error { id, err := primitive.ObjectIDFromHex(idString) if err != nil { diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go index c0e9f8d7e9..0686fa2bed 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go @@ -48,7 +48,14 @@ func (c *ReleasePlanLogColl) GetCollectionName() string { } func (c *ReleasePlanLogColl) EnsureIndex(ctx context.Context) error { - return nil + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err } func (c *ReleasePlanLogColl) Create(args *models.ReleasePlanLog) error { @@ -76,7 +83,7 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo ctx := context.Background() opts := options.Find() if opt.IsSort { - opts.SetSort(bson.D{{"create_time", -1}}) + opts.SetSort(bson.D{{"created_at", -1}}) } if opt.PlanID != "" { query["plan_id"] = opt.PlanID diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go new file mode 100644 index 0000000000..5d90bf4e20 --- /dev/null +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go @@ -0,0 +1,89 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mongodb + +import ( + "context" + + "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + mongotool "github.com/koderover/zadig/v2/pkg/tool/mongo" +) + +type ReleasePlanVersionColl struct { + *mongo.Collection + + coll string +} + +func NewReleasePlanVersionColl() *ReleasePlanVersionColl { + name := models.ReleasePlanVersion{}.TableName() + return &ReleasePlanVersionColl{ + Collection: mongotool.Database(config.MongoDatabase()).Collection(name), + coll: name, + } +} + +func (c *ReleasePlanVersionColl) GetCollectionName() string { + return c.coll +} + +func (c *ReleasePlanVersionColl) EnsureIndex(ctx context.Context) error { + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "version", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err +} + +func (c *ReleasePlanVersionColl) Create(args *models.ReleasePlanVersion) error { + if args == nil { + return errors.New("nil ReleasePlanVersion") + } + + _, err := c.InsertOne(context.Background(), args) + return err +} + +func (c *ReleasePlanVersionColl) Get(planID string, version int64) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + "version": version, + }).Decode(resp) + return resp, err +} + +func (c *ReleasePlanVersionColl) GetLatest(planID string) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp) + return resp, err +} diff --git a/pkg/microservice/aslan/core/release_plan/handler/release_plan.go b/pkg/microservice/aslan/core/release_plan/handler/release_plan.go index e8ea19963d..5471eea658 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/handler/release_plan.go @@ -19,6 +19,7 @@ package handler import ( "fmt" "strings" + "strconv" "github.com/gin-gonic/gin" @@ -78,6 +79,56 @@ func GetReleasePlanLogs(c *gin.Context) { ctx.Resp, ctx.RespErr = service.GetReleasePlanLogs(c.Param("id")) } +func GetReleasePlanCollaborationEditors(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.Logger.Errorf("failed to generate authorization info for user: %s, error: %s", ctx.UserID, err) + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + ctx.Resp, ctx.RespErr = service.GetReleasePlanCollaborationEditors(c.Param("id")) +} + +func ReleasePlanCollaborationWS(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.Logger.Errorf("failed to generate authorization info for user: %s, error: %s", ctx.UserID, err) + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + ctx.RespErr = service.OpenReleasePlanCollaborationWS(c, ctx, c.Param("id")) +} + func CreateReleasePlan(c *gin.Context) { ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() @@ -189,6 +240,36 @@ func UpdateReleasePlan(c *gin.Context) { ctx.RespErr = service.UpdateReleasePlan(ctx, c.Param("id"), req) } +func GetReleasePlanVersionDiff(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + version, err := strconv.ParseInt(c.Param("version"), 10, 64) + if err != nil { + ctx.RespErr = e.ErrInvalidParam.AddDesc(err.Error()) + return + } + + ctx.Resp, ctx.RespErr = service.GetReleasePlanVersionDiff(c.Param("id"), version) +} + func GetReleasePlanJobDetail(c *gin.Context) { ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() diff --git a/pkg/microservice/aslan/core/release_plan/handler/router.go b/pkg/microservice/aslan/core/release_plan/handler/router.go index f75f4aefc4..10f8edd91c 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/router.go +++ b/pkg/microservice/aslan/core/release_plan/handler/router.go @@ -28,7 +28,10 @@ func (*Router) Inject(router *gin.RouterGroup) { v1.POST("/:id/copy", CopyReleasePlan) v1.GET("/:id", GetReleasePlan) v1.GET("/:id/logs", GetReleasePlanLogs) + v1.GET("/:id/collaboration/editors", GetReleasePlanCollaborationEditors) + v1.GET("/:id/collaboration/ws", ReleasePlanCollaborationWS) v1.PUT("/:id", UpdateReleasePlan) + v1.GET("/:id/versions/:version/diff", GetReleasePlanVersionDiff) v1.GET("/:id/job/:jobID", GetReleasePlanJobDetail) v1.DELETE("/:id", DeleteReleasePlan) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go new file mode 100644 index 0000000000..d87914e25d --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -0,0 +1,672 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/pkg/errors" + + configbase "github.com/koderover/zadig/v2/pkg/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" + "github.com/koderover/zadig/v2/pkg/shared/handler" + "github.com/koderover/zadig/v2/pkg/tool/cache" + e "github.com/koderover/zadig/v2/pkg/tool/errors" + "github.com/koderover/zadig/v2/pkg/tool/log" + "github.com/koderover/zadig/v2/pkg/util" +) + +const ( + releasePlanCollabSessionKeyPrefix = "release-plan:collab:session:" + releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" + releasePlanCollabBroadcastChannel = "release-plan-collaboration" + releasePlanCollabSessionTTL = 90 * time.Second + releasePlanCollabBroadcastTTL = 5 * time.Minute +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: checkReleasePlanCollaborationOrigin, +} + +type ReleasePlanEditingSession struct { + PlanID string `json:"plan_id"` + SessionID string `json:"session_id"` + ConnectionID string `json:"connection_id,omitempty"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Account string `json:"account"` + IdentityType string `json:"identity_type,omitempty"` + Avatar string `json:"avatar,omitempty"` + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + BaseVersion int64 `json:"base_version"` + EditingStartedAt int64 `json:"editing_started_at"` + LastHeartbeatAt int64 `json:"last_heartbeat_at"` +} + +type ReleasePlanCollaborationGroup struct { + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + Editors []*ReleasePlanEditingSession `json:"editors"` +} + +type ReleasePlanCollaborationSnapshot struct { + PlanID string `json:"plan_id"` + PlanVersion int64 `json:"plan_version"` + Groups []*ReleasePlanCollaborationGroup `json:"groups"` +} + +type releasePlanCollabWSMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id,omitempty"` + SectionKey string `json:"section_key,omitempty"` + SectionType string `json:"section_type,omitempty"` + SectionName string `json:"section_name,omitempty"` + BaseVersion int64 `json:"base_version,omitempty"` +} + +type releasePlanCollabWSOutbound struct { + Type string `json:"type"` + Snapshot *ReleasePlanCollaborationSnapshot `json:"snapshot,omitempty"` + Error string `json:"error,omitempty"` +} + +type collaborationClient struct { + planID string + id string + conn *websocket.Conn + send chan []byte + + sessionMu sync.Mutex + sessionIDs map[string]struct{} +} + +var collaborationHub = struct { + sync.RWMutex + clients map[string]map[*collaborationClient]struct{} +}{ + clients: map[string]map[*collaborationClient]struct{}{}, +} + +var collaborationLoopOnce sync.Once + +func ensureReleasePlanCollaborationLoop() { + collaborationLoopOnce.Do(func() { + util.Go(func() { + ch, closeFn := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Subscribe(releasePlanCollabBroadcastChannel) + defer closeFn() + + for msg := range ch { + planID := strings.TrimSpace(msg.Payload) + if planID == "" { + continue + } + broadcastReleasePlanCollaborationSnapshot(planID) + } + }) + }) +} + +func releasePlanCollabSessionKey(sessionID string) string { + return releasePlanCollabSessionKeyPrefix + sessionID +} + +func releasePlanCollabPlanSetKey(planID string) string { + return fmt.Sprintf("%s%s:sessions", releasePlanCollabPlanSetPrefix, planID) +} + +func checkReleasePlanCollaborationOrigin(r *http.Request) bool { + if r == nil { + return false + } + + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + + originURL, err := url.Parse(origin) + if err != nil { + return false + } + + expectedHost := releasePlanRequestHost(r) + if expectedHost == "" { + return false + } + + originHost, originPort := splitReleasePlanHostPort(originURL.Host) + requestHost, requestPort := splitReleasePlanHostPort(expectedHost) + if originHost == "" || requestHost == "" { + return false + } + if !strings.EqualFold(originHost, requestHost) { + return false + } + if originPort != "" && requestPort != "" && originPort != requestPort { + return false + } + + return true +} + +func releasePlanRequestHost(r *http.Request) string { + if r == nil { + return "" + } + if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" { + if idx := strings.Index(forwardedHost, ","); idx >= 0 { + forwardedHost = forwardedHost[:idx] + } + return strings.TrimSpace(forwardedHost) + } + return strings.TrimSpace(r.Host) +} + +func splitReleasePlanHostPort(rawHost string) (string, string) { + rawHost = strings.TrimSpace(rawHost) + if rawHost == "" { + return "", "" + } + + if host, port, err := net.SplitHostPort(rawHost); err == nil { + return strings.ToLower(host), port + } + + parsed := &url.URL{Host: rawHost} + return strings.ToLower(parsed.Hostname()), parsed.Port() +} + +func broadcastReleasePlanCollaboration(planID string) { + if planID == "" { + return + } + _ = cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Publish(releasePlanCollabBroadcastChannel, planID) +} + +func registerCollaborationClient(planID string, client *collaborationClient) { + collaborationHub.Lock() + defer collaborationHub.Unlock() + + if _, exists := collaborationHub.clients[planID]; !exists { + collaborationHub.clients[planID] = make(map[*collaborationClient]struct{}) + } + collaborationHub.clients[planID][client] = struct{}{} +} + +func unregisterCollaborationClient(planID string, client *collaborationClient) { + collaborationHub.Lock() + defer collaborationHub.Unlock() + + if _, exists := collaborationHub.clients[planID]; !exists { + return + } + delete(collaborationHub.clients[planID], client) + if len(collaborationHub.clients[planID]) == 0 { + delete(collaborationHub.clients, planID) + } +} + +func rememberCollaborationClientSession(client *collaborationClient, sessionID string) { + if client == nil || sessionID == "" { + return + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + if client.sessionIDs == nil { + client.sessionIDs = make(map[string]struct{}) + } + client.sessionIDs[sessionID] = struct{}{} +} + +func forgetCollaborationClientSession(client *collaborationClient, sessionID string) { + if client == nil || sessionID == "" { + return + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + delete(client.sessionIDs, sessionID) +} + +func listCollaborationClientSessionIDs(client *collaborationClient) []string { + if client == nil { + return nil + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + resp := make([]string, 0, len(client.sessionIDs)) + for sessionID := range client.sessionIDs { + resp = append(resp, sessionID) + } + sort.Strings(resp) + return resp +} + +func shouldCleanupReleasePlanEditingSession(session *ReleasePlanEditingSession, connectionID string) bool { + if session == nil || connectionID == "" { + return false + } + return session.ConnectionID == connectionID +} + +func cleanupReleasePlanEditingSessionsForClient(client *collaborationClient) { + if client == nil || client.planID == "" { + return + } + + for _, sessionID := range listCollaborationClientSessionIDs(client) { + session, err := getReleasePlanEditingSession(client.planID, sessionID) + if err != nil { + continue + } + if !shouldCleanupReleasePlanEditingSession(session, client.id) { + continue + } + if err := removeReleasePlanEditingSession(client.planID, sessionID); err != nil { + log.Errorf("remove release plan editing session on disconnect error: %v", err) + continue + } + forgetCollaborationClientSession(client, sessionID) + } +} + +func sendSnapshotToLocalClients(planID string, snapshot *ReleasePlanCollaborationSnapshot) { + if snapshot == nil { + return + } + payload, err := json.Marshal(&releasePlanCollabWSOutbound{ + Type: "snapshot", + Snapshot: snapshot, + }) + if err != nil { + return + } + + collaborationHub.RLock() + clients := make([]*collaborationClient, 0, len(collaborationHub.clients[planID])) + for client := range collaborationHub.clients[planID] { + clients = append(clients, client) + } + collaborationHub.RUnlock() + + for _, client := range clients { + select { + case client.send <- payload: + default: + _ = client.conn.Close() + } + } +} + +func queueCollaborationClientMessage(client *collaborationClient, outbound *releasePlanCollabWSOutbound) { + if client == nil || outbound == nil { + return + } + payload, err := json.Marshal(outbound) + if err != nil { + return + } + select { + case client.send <- payload: + default: + } +} + +func broadcastReleasePlanCollaborationSnapshot(planID string) { + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err != nil { + log.Errorf("get release plan collaboration snapshot error: %v", err) + return + } + sendSnapshotToLocalClients(planID, snapshot) +} + +func GetReleasePlanCollaborationEditors(planID string) (*ReleasePlanCollaborationSnapshot, error) { + return GetReleasePlanCollaborationSnapshot(planID) +} + +func GetReleasePlanCollaborationSnapshot(planID string) (*ReleasePlanCollaborationSnapshot, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "get plan") + } + + editors, err := listActiveReleasePlanEditingSessions(planID) + if err != nil { + return nil, err + } + + groupMap := map[string]*ReleasePlanCollaborationGroup{} + groupOrder := make([]string, 0) + for _, session := range editors { + key := session.SectionKey + group, exists := groupMap[key] + if !exists { + group = &ReleasePlanCollaborationGroup{ + SectionKey: session.SectionKey, + SectionType: session.SectionType, + SectionName: session.SectionName, + Editors: make([]*ReleasePlanEditingSession, 0), + } + groupMap[key] = group + groupOrder = append(groupOrder, key) + } + displaySession := *session + displaySession.ConnectionID = "" + group.Editors = append(group.Editors, &displaySession) + } + + sort.Strings(groupOrder) + resp := make([]*ReleasePlanCollaborationGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + resp = append(resp, groupMap[key]) + } + + return &ReleasePlanCollaborationSnapshot{ + PlanID: planID, + PlanVersion: plan.Version, + Groups: resp, + }, nil +} + +func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingSession, error) { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + sessionIDs, err := redisCache.ListSetMembers(releasePlanCollabPlanSetKey(planID)) + if err != nil { + return nil, err + } + + resp := make([]*ReleasePlanEditingSession, 0, len(sessionIDs)) + for _, sessionID := range sessionIDs { + value, err := redisCache.GetString(releasePlanCollabSessionKey(sessionID)) + if err != nil { + continue + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(value), session); err != nil { + continue + } + if session.PlanID != planID { + continue + } + resp = append(resp, session) + } + + sort.Slice(resp, func(i, j int) bool { + if resp[i].SectionKey == resp[j].SectionKey { + return resp[i].EditingStartedAt < resp[j].EditingStartedAt + } + return resp[i].SectionKey < resp[j].SectionKey + }) + + return resp, nil +} + +func persistReleasePlanEditingSession(session *ReleasePlanEditingSession) error { + if session == nil { + return errors.New("nil editing session") + } + if session.PlanID == "" || session.SessionID == "" { + return errors.New("missing session id or plan id") + } + if session.EditingStartedAt == 0 { + session.EditingStartedAt = time.Now().Unix() + } + session.LastHeartbeatAt = time.Now().Unix() + + payload, err := json.Marshal(session) + if err != nil { + return err + } + + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Write(releasePlanCollabSessionKey(session.SessionID), string(payload), releasePlanCollabSessionTTL); err != nil { + return err + } + if err := redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabBroadcastTTL); err != nil { + return err + } + broadcastReleasePlanCollaboration(session.PlanID) + return nil +} + +func removeReleasePlanEditingSession(planID, sessionID string) error { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Delete(releasePlanCollabSessionKey(sessionID)); err != nil { + return err + } + if err := redisCache.RemoveElementsFromSet(releasePlanCollabPlanSetKey(planID), []string{sessionID}); err != nil { + return err + } + broadcastReleasePlanCollaboration(planID) + return nil +} + +func authorizeReleasePlanEditing(ctx *handler.Context, sectionType string) bool { + if ctx.Resources.IsSystemAdmin { + return true + } + switch sectionType { + case "metadata": + return ctx.Resources.SystemActions.ReleasePlan.EditMetadata + case "approval": + return ctx.Resources.SystemActions.ReleasePlan.EditApproval + case "job": + return ctx.Resources.SystemActions.ReleasePlan.EditSubtasks + default: + return false + } +} + +func validateReleasePlanEditingPlan(plan *models.ReleasePlan) error { + if plan == nil { + return errors.New("nil plan") + } + if plan.Status != config.ReleasePlanStatusPlanning { + return errors.Errorf("plan status is %s, can not edit", plan.Status) + } + return nil +} + +func getReleasePlanEditingSession(planID, sessionID string) (*ReleasePlanEditingSession, error) { + if sessionID == "" { + return nil, errors.New("empty session id") + } + value, err := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).GetString(releasePlanCollabSessionKey(sessionID)) + if err != nil { + return nil, err + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(value), session); err != nil { + return nil, err + } + if session.PlanID != planID { + return nil, errors.New("session does not belong to current plan") + } + return session, nil +} + +func canManageReleasePlanEditingSession(session *ReleasePlanEditingSession, userID string, isSystemAdmin bool) bool { + if isSystemAdmin { + return true + } + if session == nil || userID == "" { + return false + } + return session.UserID == userID +} + +func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { + return openReleasePlanCollaborationWS(gCtx, ctx, planID) +} + +func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { + ws, err := upgrader.Upgrade(gCtx.Writer, gCtx.Request, nil) + if err != nil { + return e.ErrInvalidParam.AddErr(err) + } + defer ws.Close() + + ensureReleasePlanCollaborationLoop() + + client := &collaborationClient{ + planID: planID, + id: uuid.NewString(), + conn: ws, + send: make(chan []byte, 16), + sessionIDs: map[string]struct{}{}, + } + registerCollaborationClient(planID, client) + defer cleanupReleasePlanEditingSessionsForClient(client) + defer unregisterCollaborationClient(planID, client) + + done := make(chan struct{}) + util.Go(func() { + defer close(done) + for { + _, payload, err := ws.ReadMessage() + if err != nil { + return + } + + msg := new(releasePlanCollabWSMessage) + if err := json.Unmarshal(payload, msg); err != nil { + continue + } + + switch msg.Type { + case "join", "focus_section", "heartbeat": + if !authorizeReleasePlanEditing(ctx, msg.SectionType) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), planID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if err := validateReleasePlanEditingPlan(plan); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + existingSession, _ := getReleasePlanEditingSession(planID, msg.SessionID) + if existingSession != nil && !canManageReleasePlanEditingSession(existingSession, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + session := &ReleasePlanEditingSession{ + PlanID: planID, + SessionID: msg.SessionID, + ConnectionID: client.id, + UserID: ctx.UserID, + UserName: ctx.UserName, + Account: ctx.Account, + IdentityType: ctx.IdentityType, + SectionKey: msg.SectionKey, + SectionType: msg.SectionType, + SectionName: msg.SectionName, + BaseVersion: msg.BaseVersion, + EditingStartedAt: time.Now().Unix(), + } + if existingSession != nil { + session.EditingStartedAt = existingSession.EditingStartedAt + if session.BaseVersion == 0 { + session.BaseVersion = existingSession.BaseVersion + } + if existingSession.SectionKey != "" && existingSession.SectionKey != msg.SectionKey { + session.EditingStartedAt = time.Now().Unix() + session.BaseVersion = 0 + } + } + if session.BaseVersion == 0 { + session.BaseVersion = plan.Version + } + if err := persistReleasePlanEditingSession(session); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + rememberCollaborationClientSession(client, msg.SessionID) + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + case "leave": + session, err := getReleasePlanEditingSession(planID, msg.SessionID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if !canManageReleasePlanEditingSession(session, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + forgetCollaborationClientSession(client, msg.SessionID) + } + } + }) + + util.Go(func() { + for { + select { + case payload := <-client.send: + if err := ws.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-done: + return + } + } + }) + + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + + <-done + return nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go new file mode 100644 index 0000000000..b6628c6efc --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "net/http" + "testing" +) + +func TestCanManageReleasePlanEditingSession(t *testing.T) { + session := &ReleasePlanEditingSession{ + SessionID: "session-1", + UserID: "owner", + } + + if !canManageReleasePlanEditingSession(session, "owner", false) { + t.Fatalf("expected session owner to manage editing session") + } + if canManageReleasePlanEditingSession(session, "viewer", false) { + t.Fatalf("expected non-owner to be denied") + } + if !canManageReleasePlanEditingSession(session, "viewer", true) { + t.Fatalf("expected system admin to manage editing session") + } + if canManageReleasePlanEditingSession(nil, "owner", false) { + t.Fatalf("expected nil session to be denied") + } +} + +func TestCheckReleasePlanCollaborationOrigin(t *testing.T) { + t.Run("allow empty origin", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected empty origin to be allowed") + } + }) + + t.Run("allow same host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://internal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + req.Header.Set("Origin", "https://zadig.example.com") + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected same origin host to be allowed") + } + }) + + t.Run("allow forwarded host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://internal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "aslan:25000" + req.Header.Set("X-Forwarded-Host", "zadig.example.com") + req.Header.Set("Origin", "https://zadig.example.com") + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected forwarded host to be honored") + } + }) + + t.Run("reject cross origin host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + req.Header.Set("Origin", "https://evil.example.com") + + if checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected cross origin host to be rejected") + } + }) +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go new file mode 100644 index 0000000000..b000bc3a04 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -0,0 +1,1448 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanHashPruneMinMapKeys = 4 + releasePlanHashPruneMinArrayItems = 4 + releasePlanDiffChangeTypeOrder = "order_changed" +) + +type ReleasePlanVersionDiffResponse struct { + PlanID string `json:"plan_id"` + Version int64 `json:"version"` + PreviousVersion int64 `json:"previous_version"` + Groups []*ReleasePlanVersionDiffGroup `json:"groups"` +} + +type ReleasePlanVersionDiffGroup struct { + GroupKey string `json:"group_key"` + GroupName string `json:"group_name"` + GroupType string `json:"group_type"` + Changes []*ReleasePlanVersionDiffChange `json:"changes"` +} + +type ReleasePlanVersionDiffOrderItem struct { + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type ReleasePlanVersionDiffChange struct { + TaskName string `json:"task_name,omitempty"` + TaskType string `json:"task_type,omitempty"` + ChangeType string `json:"change_type,omitempty"` + Path string `json:"path"` + Label string `json:"label"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` + BeforeOrder []*ReleasePlanVersionDiffOrderItem `json:"before_order,omitempty"` + AfterOrder []*ReleasePlanVersionDiffOrderItem `json:"after_order,omitempty"` + LargeText bool `json:"large_text,omitempty"` + Masked bool `json:"masked,omitempty"` +} + +type releasePlanRawDiffEntry struct { + Path string + ChangeType string + Before interface{} + After interface{} + BeforeOrder []*ReleasePlanVersionDiffOrderItem + AfterOrder []*ReleasePlanVersionDiffOrderItem +} + +type releasePlanDiffContext struct { + GroupType string +} + +type releasePlanArrayDiffStrategy int + +const ( + releasePlanArrayDiffStrategyIndex releasePlanArrayDiffStrategy = iota + releasePlanArrayDiffStrategyKeyedUnordered + releasePlanArrayDiffStrategyKeyedOrdered +) + +type releasePlanArrayDiffRuleMatchType int + +const ( + releasePlanArrayDiffRuleMatchTypeExact releasePlanArrayDiffRuleMatchType = iota + releasePlanArrayDiffRuleMatchTypeSafeSuffix +) + +type releasePlanArrayKeyBuilder func(item interface{}) (string, bool) + +type releasePlanArrayDiffRule struct { + GroupType string + Path string + ParentJobTypes map[string]struct{} + MatchType releasePlanArrayDiffRuleMatchType + Strategy releasePlanArrayDiffStrategy + BuildKey releasePlanArrayKeyBuilder +} + +func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + MatchType: releasePlanArrayDiffRuleMatchTypeExact, + Strategy: strategy, + BuildKey: buildKey, + } +} + +func newReleasePlanTypedExactArrayRule(groupType, path string, parentJobTypes []config.JobType, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + rule := newReleasePlanExactArrayRule(groupType, path, strategy, buildKey) + rule.ParentJobTypes = make(map[string]struct{}, len(parentJobTypes)) + for _, jobType := range parentJobTypes { + rule.ParentJobTypes[string(jobType)] = struct{}{} + } + return rule +} + +func newReleasePlanSafeSuffixArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + MatchType: releasePlanArrayDiffRuleMatchTypeSafeSuffix, + Strategy: strategy, + BuildKey: buildKey, + } +} + +var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ + newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), + newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_test_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.source_service", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByFixedWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_module_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sBlueGreenDeploy, config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alerts", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alert_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.monitors", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_group_notification_config.at_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_person_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), +} + +var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ + newReleasePlanSafeSuffixArrayRule("job", "repos", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), + newReleasePlanSafeSuffixArrayRule("job", "code_info", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), + newReleasePlanSafeSuffixArrayRule("job", "key_vals", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_annotations", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_labels", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "variable_kvs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "kv", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "original_config", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), +} + +var releasePlanFieldLabels = map[string]string{ + "name": "名称", + "manager": "负责人", + "manager_id": "负责人 ID", + "start_time": "开始时间", + "end_time": "结束时间", + "schedule_execute_time": "定时执行时间", + "description": "需求关联", + "approval": "审批配置", + "type": "类型", + "enabled": "是否启用", + "content": "内容", + "remark": "备注", + "branch": "代码分支", + "tag": "Tag", + "pr": "PR", + "repo_name": "仓库名称", + "repo_namespace": "仓库命名空间", + "remote_name": "远端名称", + "job_name": "任务名称", + "build_name": "构建名称", + "service_name": "服务名称", + "service_module": "服务组件", + "image": "镜像", + "image_name": "镜像名称", + "namespace": "命名空间", + "env": "环境", + "cluster_id": "集群", + "cluster_source": "集群来源", + "target": "目标", + "targets": "目标列表", + "key_vals": "变量", + "key": "变量名", + "value": "变量值", + "order": "顺序", + "params": "参数", + "stages": "阶段", + "jobs": "任务", + "script": "脚本内容", + "sql": "SQL 内容", + "manual_exec_users": "人工执行用户", + "approve_users": "审批人", + "approval_nodes": "审批节点", + "services": "服务", + "service_and_builds": "构建对象", + "default_service_and_builds": "默认构建对象", + "repos": "代码仓库", + "workflow": "工作流", + "native_approval": "原生审批", + "lark_approval": "飞书审批", + "dingtalk_approval": "钉钉审批", + "workwx_approval": "企业微信审批", +} + +func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersionDiffResponse, error) { + current, err := mongodb.NewReleasePlanVersionColl().Get(planID, version) + if err != nil { + return nil, errors.Wrap(err, "get version") + } + + fromData, err := toGenericValue(current.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "convert base snapshot") + } + toData, err := toGenericValue(current.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert current snapshot") + } + + groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) + + rawEntries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries) + + groupMap := map[string]*ReleasePlanVersionDiffGroup{} + groupOrder := make([]string, 0) + for _, entry := range rawEntries { + if shouldIgnoreReleasePlanDiffPath(entry.Path) { + continue + } + taskName, taskType := classifyReleasePlanDiffTask(entry.Path) + group, exists := groupMap[groupKey] + if !exists { + group = &ReleasePlanVersionDiffGroup{ + GroupKey: groupKey, + GroupName: groupName, + GroupType: groupType, + Changes: make([]*ReleasePlanVersionDiffChange, 0), + } + groupMap[groupKey] = group + groupOrder = append(groupOrder, groupKey) + } + + change := &ReleasePlanVersionDiffChange{ + TaskName: taskName, + TaskType: taskType, + ChangeType: entry.ChangeType, + Path: entry.Path, + Label: buildReleasePlanDiffLabel(entry.Path), + } + if entry.ChangeType == releasePlanDiffChangeTypeOrder { + change.BeforeOrder = entry.BeforeOrder + change.AfterOrder = entry.AfterOrder + } else if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { + change.Masked = true + } else if isLargeTextReleasePlanDiffPath(entry.Path, entry.Before, entry.After) { + change.LargeText = true + } else { + change.Before = normalizeReleasePlanDiffValue(entry.Before) + change.After = normalizeReleasePlanDiffValue(entry.After) + } + group.Changes = append(group.Changes, change) + } + + sort.Strings(groupOrder) + groups := make([]*ReleasePlanVersionDiffGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + group := groupMap[key] + sort.Slice(group.Changes, func(i, j int) bool { + return group.Changes[i].Path < group.Changes[j].Path + }) + groups = append(groups, group) + } + + return &ReleasePlanVersionDiffResponse{ + PlanID: planID, + Version: version, + PreviousVersion: previousReleasePlanVersion(version), + Groups: groups, + }, nil +} + +func previousReleasePlanVersion(version int64) int64 { + if version <= 1 { + return 0 + } + return version - 1 +} + +func toGenericValue(value interface{}) (interface{}, error) { + if value == nil { + return nil, nil + } + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + var resp interface{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { + if shouldIgnoreReleasePlanDiffPath(path) { + return + } + + if equal, hashed := equalReleasePlanSubtreeByHash(left, right); hashed { + if equal { + return + } + } else if reflect.DeepEqual(left, right) { + return + } + + leftMap, leftIsMap := left.(map[string]interface{}) + rightMap, rightIsMap := right.(map[string]interface{}) + if leftIsMap || rightIsMap { + keys := make([]string, 0) + keySet := map[string]struct{}{} + for key := range leftMap { + keySet[key] = struct{}{} + } + for key := range rightMap { + keySet[key] = struct{}{} + } + for key := range keySet { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + nextPath := joinReleasePlanDiffPath(path, key) + diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) + } + return + } + + leftList, leftIsList := left.([]interface{}) + rightList, rightIsList := right.([]interface{}) + if leftIsList || rightIsList { + diffReleasePlanArray(ctx, path, leftList, rightList, entries) + return + } + + *entries = append(*entries, &releasePlanRawDiffEntry{ + Path: path, + Before: left, + After: right, + }) +} + +func equalReleasePlanSubtreeByHash(left, right interface{}) (equal bool, hashed bool) { + if !shouldUseReleasePlanSubtreeHash(left, right) { + return false, false + } + + leftHash, err := hashReleasePlanSubtree(left) + if err != nil { + return false, false + } + rightHash, err := hashReleasePlanSubtree(right) + if err != nil { + return false, false + } + return leftHash == rightHash, true +} + +func shouldUseReleasePlanSubtreeHash(left, right interface{}) bool { + switch leftValue := left.(type) { + case map[string]interface{}: + rightValue, ok := right.(map[string]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinMapKeys || len(rightValue) >= releasePlanHashPruneMinMapKeys + case []interface{}: + rightValue, ok := right.([]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinArrayItems || len(rightValue) >= releasePlanHashPruneMinArrayItems + default: + return false + } +} + +func hashReleasePlanSubtree(value interface{}) (string, error) { + payload, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]), nil +} + +func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { + rule := matchReleasePlanArrayDiffRule(ctx, path) + if rule == nil || rule.Strategy == releasePlanArrayDiffStrategyIndex { + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + return + } + + leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left, rule.BuildKey) + rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right, rule.BuildKey) + if !leftMapped || !rightMapped { + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + return + } + + strategy := rule.Strategy + if strategy == releasePlanArrayDiffStrategyKeyedOrdered { + if entry := buildReleasePlanArrayOrderChange(path, left, right, leftMap, leftOrdered, rightMap, rightOrdered); entry != nil { + *entries = append(*entries, entry) + } + } + if strategy == releasePlanArrayDiffStrategyKeyedOrdered || strategy == releasePlanArrayDiffStrategyKeyedUnordered { + keySet := map[string]struct{}{} + keys := make([]string, 0) + for _, key := range leftOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range rightOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range keys { + nextPath := fmt.Sprintf("%s[%s]", path, key) + diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) + } + return + } + + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) +} + +func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { + maxLen := len(left) + if len(right) > maxLen { + maxLen = len(right) + } + for i := 0; i < maxLen; i++ { + nextPath := fmt.Sprintf("%s[%d]", path, i) + var leftVal, rightVal interface{} + if i < len(left) { + leftVal = left[i] + } + if i < len(right) { + rightVal = right[i] + } + diffReleasePlanValues(ctx, nextPath, leftVal, rightVal, entries) + } +} + +type releasePlanArrayRuleLookupContext struct { + GroupType string + Path string + ParentJobType string +} + +func matchReleasePlanArrayDiffRule(ctx releasePlanDiffContext, path string) *releasePlanArrayDiffRule { + lookupContexts := buildReleasePlanArrayRuleLookupContexts(ctx, path) + for _, lookup := range lookupContexts { + for idx := range releasePlanArrayExactRules { + rule := &releasePlanArrayExactRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if !matchesReleasePlanParentJobType(rule, lookup.ParentJobType) { + continue + } + if rule.Path == lookup.Path { + return rule + } + } + } + for _, lookup := range lookupContexts { + for idx := range releasePlanArraySafeSuffixRules { + rule := &releasePlanArraySafeSuffixRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if lookup.Path == rule.Path || strings.HasSuffix(lookup.Path, "."+rule.Path) { + return rule + } + } + } + return nil +} + +func matchesReleasePlanParentJobType(rule *releasePlanArrayDiffRule, parentJobType string) bool { + if len(rule.ParentJobTypes) == 0 { + return true + } + _, ok := rule.ParentJobTypes[parentJobType] + return ok +} + +func buildReleasePlanArrayRuleLookupContexts(ctx releasePlanDiffContext, path string) []releasePlanArrayRuleLookupContext { + normalizedPath := normalizeReleasePlanDiffPath(path) + parentJobType := extractReleasePlanParentJobType(path) + resp := []releasePlanArrayRuleLookupContext{{ + GroupType: ctx.GroupType, + Path: normalizedPath, + ParentJobType: parentJobType, + }} + + if ctx.GroupType != "plan" { + return resp + } + + // Nested arrays under the plan snapshot still belong to job/approval/metadata structures. + if strings.HasPrefix(normalizedPath, "jobs.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "job", + Path: strings.TrimPrefix(normalizedPath, "jobs."), + ParentJobType: parentJobType, + }) + } + if strings.HasPrefix(normalizedPath, "approval.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "approval", + Path: strings.TrimPrefix(normalizedPath, "approval."), + ParentJobType: parentJobType, + }) + } + if strings.HasPrefix(normalizedPath, "metadata.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "metadata", + Path: strings.TrimPrefix(normalizedPath, "metadata."), + ParentJobType: parentJobType, + }) + } + return resp +} + +func extractReleasePlanParentJobType(path string) string { + parentJobType := "" + searchPath := path + for { + idx := strings.Index(searchPath, "jobs[") + if idx < 0 { + return parentJobType + } + searchPath = searchPath[idx+len("jobs["):] + endIdx := strings.IndexByte(searchPath, ']') + if endIdx < 0 { + return parentJobType + } + key := searchPath[:endIdx] + if jobType, ok := extractReleasePlanJobTypeFromArrayKey(key); ok { + parentJobType = jobType + } + searchPath = searchPath[endIdx+1:] + } +} + +func extractReleasePlanJobTypeFromArrayKey(key string) (string, bool) { + parts := strings.Split(key, "|") + if len(parts) < 2 { + return "", false + } + return trimReleasePlanArrayDuplicateSuffix(parts[1]), true +} + +func trimReleasePlanArrayDuplicateSuffix(value string) string { + idx := strings.LastIndex(value, "#") + if idx < 0 || idx == len(value)-1 { + return value + } + for _, ch := range value[idx+1:] { + if ch < '0' || ch > '9' { + return value + } + } + return value[:idx] +} + +func normalizeReleasePlanDiffPath(path string) string { + if path == "" { + return "" + } + + builder := strings.Builder{} + builder.Grow(len(path)) + inBracket := false + for _, ch := range path { + switch ch { + case '[': + inBracket = true + case ']': + inBracket = false + default: + if !inBracket { + builder.WriteRune(ch) + } + } + } + return builder.String() +} + +func buildReleasePlanArrayOrderChange( + path string, + left, right []interface{}, + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) *releasePlanRawDiffEntry { + if !hasReleasePlanArrayRelativeOrderChange(leftMap, leftOrdered, rightMap, rightOrdered) { + return nil + } + + return &releasePlanRawDiffEntry{ + Path: joinReleasePlanDiffPath(path, "order"), + ChangeType: releasePlanDiffChangeTypeOrder, + BeforeOrder: buildReleasePlanArrayOrderItems(left, leftOrdered), + AfterOrder: buildReleasePlanArrayOrderItems(right, rightOrdered), + } +} + +func hasReleasePlanArrayRelativeOrderChange( + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) bool { + leftShared := filterReleasePlanArrayOrderedKeys(leftOrdered, rightMap) + rightShared := filterReleasePlanArrayOrderedKeys(rightOrdered, leftMap) + return !reflect.DeepEqual(leftShared, rightShared) +} + +func filterReleasePlanArrayOrderedKeys(orderedKeys []string, otherMap map[string]interface{}) []string { + resp := make([]string, 0, len(orderedKeys)) + for _, key := range orderedKeys { + if _, exists := otherMap[key]; exists { + resp = append(resp, key) + } + } + return resp +} + +func buildReleasePlanArrayOrderItems(values []interface{}, orderedKeys []string) []*ReleasePlanVersionDiffOrderItem { + resp := make([]*ReleasePlanVersionDiffOrderItem, 0, len(values)) + for idx, item := range values { + key := "" + if idx < len(orderedKeys) { + key = orderedKeys[idx] + } + resp = append(resp, buildReleasePlanArrayOrderItem(item, key)) + } + return resp +} + +func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVersionDiffOrderItem { + resp := &ReleasePlanVersionDiffOrderItem{Key: key} + + switch value := item.(type) { + case map[string]interface{}: + if id, ok := getStringField(value, "id"); ok { + resp.ID = id + } + if name, ok := getStringField(value, "name"); ok { + resp.Name = name + return resp + } + if itemKey, ok := getStringField(value, "key"); ok { + resp.Name = itemKey + return resp + } + if workflowName, ok := getStringField(value, "workflow_name"); ok { + projectName, _ := getStringField(value, "project_name") + serviceName, _ := getStringField(value, "service_name") + serviceModule, _ := getStringField(value, "service_module") + parts := make([]string, 0, 4) + if projectName != "" { + parts = append(parts, projectName) + } + if workflowName != "" { + parts = append(parts, workflowName) + } + if serviceName != "" { + parts = append(parts, serviceName) + } + if serviceModule != "" { + parts = append(parts, serviceModule) + } + resp.Name = strings.Join(parts, "/") + return resp + } + if service, ok := getStringField(value, "service_name"); ok { + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = fmt.Sprintf("%s/%s", service, module) + } else { + resp.Name = service + } + return resp + } + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = module + return resp + } + if repo, ok := getStringField(value, "repo_name"); ok { + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + resp.Name = strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/") + return resp + } + if target, ok := getStringField(value, "target"); ok { + resp.Name = target + return resp + } + if targetName := buildReleasePlanTargetOrderName(value); targetName != "" { + resp.Name = targetName + return resp + } + if userID, ok := getStringField(value, "user_id"); ok { + resp.Name = userID + return resp + } + if groupName, ok := getStringField(value, "group_name"); ok { + resp.Name = groupName + return resp + } + if sprintName, ok := getStringField(value, "sprint_name"); ok { + projectKey, _ := getStringField(value, "project_key") + if projectKey != "" { + resp.Name = fmt.Sprintf("%s/%s", projectKey, sprintName) + } else { + resp.Name = sprintName + } + return resp + } + if variableKey, ok := getStringField(value, "variable_key"); ok { + resp.Name = variableKey + return resp + } + } + + if resp.Name == "" && key != "" { + resp.Name = key + } + if resp.Name == "" { + resp.Name = fmt.Sprintf("%v", item) + } + return resp +} + +func buildReleasePlanTargetOrderName(value map[string]interface{}) string { + if serviceName, ok := getStringField(value, "k8s_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(serviceName, workloadName, containerName) + } + if virtualServiceName, ok := getStringField(value, "virtual_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(virtualServiceName, workloadName, containerName) + } + if workloadType, ok := getStringField(value, "workload_type"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(workloadType, workloadName, containerName) + } + return "" +} + +func joinReleasePlanOrderNameParts(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, " / ") +} + +func buildReleasePlanArrayMap(values []interface{}, buildKey releasePlanArrayKeyBuilder) (map[string]interface{}, []string, bool) { + if buildKey == nil { + return nil, nil, false + } + + result := make(map[string]interface{}, len(values)) + orderedKeys := make([]string, 0, len(values)) + for idx, item := range values { + key, ok := buildKey(item) + if !ok { + return nil, nil, false + } + if _, exists := result[key]; exists { + key = fmt.Sprintf("%s#%d", key, idx) + } + result[key] = item + orderedKeys = append(orderedKeys, key) + } + return result, orderedKeys, true +} + +func buildReleasePlanArrayKeyByNameTypeID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + jobType, ok := getStringField(value, "type") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", name, jobType, id), true +} + +func buildReleasePlanArrayKeyByNameType(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + itemType, ok := getStringField(value, "type") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, itemType), true +} + +func buildReleasePlanArrayKeyByNameID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, id), true +} + +func buildReleasePlanArrayKeyByName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "name") +} + +func buildReleasePlanArrayKeyByKey(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "key") +} + +func buildReleasePlanArrayKeyByTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "target") +} + +func buildReleasePlanArrayKeyByServiceModule(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + service, ok := getStringField(value, "service_name") + if !ok { + return "", false + } + module, ok := getStringField(value, "service_module") + if !ok { + return "", false + } + return fmt.Sprintf("%s/%s", service, module), true +} + +func buildReleasePlanArrayKeyByServiceModuleNameOnly(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if module, ok := getStringField(value, "service_module"); ok { + return module, true + } + return getStringField(value, "name") +} + +func buildReleasePlanArrayKeyByServiceName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "service_name") +} + +func buildReleasePlanArrayKeyByVariableConfig(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + variableKey, ok := getStringField(value, "variable_key") + if !ok { + return "", false + } + source, _ := getStringField(value, "source") + return fmt.Sprintf("%s|%s", variableKey, source), true +} + +func buildReleasePlanArrayKeyByWorkflowTrigger(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + serviceName, ok := getStringField(value, "service_name") + if !ok { + return "", false + } + workflowName, ok := getStringField(value, "workflow_name") + if !ok { + return "", false + } + projectName, ok := getStringField(value, "project_name") + if !ok { + return "", false + } + serviceModule, _ := getStringField(value, "service_module") + return fmt.Sprintf("%s|%s|%s|%s", serviceName, serviceModule, workflowName, projectName), true +} + +func buildReleasePlanArrayKeyByFixedWorkflowTrigger(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workflowName, ok := getStringField(value, "workflow_name") + if !ok { + return "", false + } + projectName, ok := getStringField(value, "project_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", workflowName, projectName), true +} + +func buildReleasePlanArrayKeyByJobName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "job_name") +} + +func buildReleasePlanArrayKeyByRepo(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + repo, ok := getStringField(value, "repo_name") + if !ok { + return "", false + } + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true +} + +func buildReleasePlanArrayKeyByUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "user_id") +} + +func buildReleasePlanArrayKeyByThirdPartyUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if id, ok := getStringField(value, "id"); ok { + return id, true + } + name, hasName := getStringField(value, "name") + userID, hasUserID := getStringField(value, "user_id") + if hasName && hasUserID { + return fmt.Sprintf("%s|%s", name, userID), true + } + return "", false +} + +func buildReleasePlanArrayKeyByApprovalGroup(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if groupID, ok := getStringField(value, "group_id"); ok { + return groupID, true + } + return getStringField(value, "group_name") +} + +func buildReleasePlanArrayKeyByJiraSprint(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + projectKey, _ := getStringField(value, "project_key") + if projectKey == "" { + projectKey, _ = getStringField(value, "project_name") + } + if projectKey == "" { + return "", false + } + boardID, ok := getNumberFieldString(value, "board_id") + if !ok { + return "", false + } + sprintID, ok := getNumberFieldString(value, "sprint_id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", projectKey, boardID, sprintID), true +} + +func buildReleasePlanArrayKeyByK8sTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + serviceName, ok := getStringField(value, "k8s_service_name") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", serviceName, workloadName, containerName), true +} + +func buildReleasePlanArrayKeyByGrayReleaseTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workloadType, ok := getStringField(value, "workload_type") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", workloadType, workloadName, containerName), true +} + +func buildReleasePlanArrayKeyByGrayRollbackTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workloadType, ok := getStringField(value, "workload_type") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", workloadType, workloadName), true +} + +func buildReleasePlanArrayKeyByIstioTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + virtualServiceName, ok := getStringField(value, "virtual_service_name") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", virtualServiceName, workloadName, containerName), true +} + +func getMapField(item interface{}) (map[string]interface{}, bool) { + value, ok := item.(map[string]interface{}) + return value, ok +} + +func getStringField(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + str, ok := value.(string) + return str, ok && str != "" +} + +func getNumberFieldString(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + switch typed := value.(type) { + case string: + return typed, typed != "" + case float64: + intValue := int64(typed) + if float64(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case float32: + intValue := int64(typed) + if float32(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case int: + return strconv.Itoa(typed), true + case int8: + return strconv.FormatInt(int64(typed), 10), true + case int16: + return strconv.FormatInt(int64(typed), 10), true + case int32: + return strconv.FormatInt(int64(typed), 10), true + case int64: + return strconv.FormatInt(typed, 10), true + case uint: + return strconv.FormatUint(uint64(typed), 10), true + case uint8: + return strconv.FormatUint(uint64(typed), 10), true + case uint16: + return strconv.FormatUint(uint64(typed), 10), true + case uint32: + return strconv.FormatUint(uint64(typed), 10), true + case uint64: + return strconv.FormatUint(typed, 10), true + case json.Number: + if intValue, err := typed.Int64(); err == nil { + return strconv.FormatInt(intValue, 10), true + } + return "", false + default: + return "", false + } +} + +func joinReleasePlanDiffPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func shouldIgnoreReleasePlanDiffPath(path string) bool { + if path == "" { + return false + } + prefixes := []string{ + "id", + "index", + "version", + "created_by", + "create_time", + "updated_by", + "update_time", + "status", + "planning_time", + "finish_planning_time", + "approval_time", + "executing_time", + "success_time", + "instance_code", + "hook_settings", + "wait_for_finish_planning_external_check_time", + "wait_for_approve_external_check_time", + "wait_for_execute_external_check_time", + "wait_for_all_done_external_check_time", + "external_check_failed_reason", + "callback_description", + } + for _, prefix := range prefixes { + if path == prefix || strings.HasPrefix(path, prefix+".") { + return true + } + } + + suffixes := []string{ + ".status", + ".last_status", + ".updated", + ".executed_by", + ".executed_time", + ".task_id", + ".hook_payload", + ".hash", + ".notification_id", + ".operation_time", + ".reject_or_approve", + ".approval_instance", + ".manual_exector_id", + ".manual_exector_name", + ".notification_sent", + } + for _, suffix := range suffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + +func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { + jobSegments := releasePlanBracketSegments(path, "jobs") + if len(jobSegments) >= 2 { + taskName, taskType = splitReleasePlanBracketKey(jobSegments[len(jobSegments)-1]) + } + return +} + +func releasePlanBracketSegments(path, prefix string) []string { + resp := make([]string, 0) + for _, segment := range strings.Split(path, ".") { + if strings.HasPrefix(segment, prefix+"[") { + resp = append(resp, segment) + } + } + return resp +} + +func splitReleasePlanBracketKey(segment string) (string, string) { + primary := bracketPrimaryName(segment) + parts := strings.Split(primary, "|") + if len(parts) == 1 { + return primary, "" + } + return parts[0], strings.Join(parts[1:], "|") +} + +func bracketPrimaryName(segment string) string { + start := strings.Index(segment, "[") + end := strings.LastIndex(segment, "]") + if start == -1 || end == -1 || end <= start+1 { + return segment + } + return segment[start+1 : end] +} + +func buildReleasePlanDiffLabel(path string) string { + segments := strings.Split(path, ".") + labels := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment == "spec" || segment == "workflow" { + continue + } + label := segment + switch { + case strings.HasPrefix(segment, "jobs["): + name, _ := splitReleasePlanBracketKey(segment) + label = fmt.Sprintf("任务 %s", name) + case strings.HasPrefix(segment, "stages["): + label = fmt.Sprintf("阶段 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "params["): + label = fmt.Sprintf("参数 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "key_vals["): + label = fmt.Sprintf("变量 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "services["): + label = fmt.Sprintf("服务 %s", bracketPrimaryName(segment)) + case strings.Contains(segment, "["): + fieldName := segment[:strings.Index(segment, "[")] + label = fmt.Sprintf("%s %s", translateReleasePlanFieldLabel(fieldName), bracketPrimaryName(segment)) + default: + label = translateReleasePlanFieldLabel(segment) + } + labels = append(labels, label) + } + if len(labels) == 0 { + return path + } + return strings.Join(labels, " / ") +} + +func translateReleasePlanFieldLabel(name string) string { + if label, exists := releasePlanFieldLabels[name]; exists { + return label + } + return strings.ReplaceAll(name, "_", " ") +} + +func isMaskedReleasePlanDiffValue(value interface{}) bool { + return isReleasePlanMaskedStorageValue(value) +} + +func isLargeTextReleasePlanDiffPath(path string, before, after interface{}) bool { + lowerPath := strings.ToLower(path) + keywords := []string{"script", "sql", "content", "yaml", "json"} + for _, keyword := range keywords { + if strings.Contains(lowerPath, keyword) { + return true + } + } + + if value, ok := before.(string); ok && len(value) > 256 { + return true + } + if value, ok := after.(string); ok && len(value) > 256 { + return true + } + return false +} + +func normalizeReleasePlanDiffValue(value interface{}) interface{} { + switch value.(type) { + case nil, string, bool, float64: + return value + default: + payload, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(payload) + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go new file mode 100644 index 0000000000..15053c8041 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -0,0 +1,127 @@ +package service + +import "testing" + +func TestGetReleasePlanArrayItemKey(t *testing.T) { + t.Run("job key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "name": "build", + "type": "zadig-build", + "id": "job-id", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "build|zadig-build|job-id" { + t.Fatalf("unexpected key: %s", key) + } + }) + + t.Run("service key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "service_name": "gateway", + "service_module": "gateway", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "gateway/gateway" { + t.Fatalf("unexpected key: %s", key) + } + }) +} + +func TestBuildReleasePlanDiffLabel(t *testing.T) { + label := buildReleasePlanDiffLabel("jobs[release-job|workflow|job-id].spec.workflow.stages[build].jobs[deploy|zadig-deploy].spec.namespace") + expected := "任务 release-job / 阶段 build / 任务 deploy / 命名空间" + if label != expected { + t.Fatalf("unexpected label: %s", label) + } +} + +func TestReleasePlanDiffPathRules(t *testing.T) { + if !shouldIgnoreReleasePlanDiffPath("update_time") { + t.Fatalf("expected update_time to be ignored") + } + if !isLargeTextReleasePlanDiffPath("jobs[deploy].spec.script", "echo 1", "echo 2") { + t.Fatalf("expected script to be marked as large text") + } +} + +func TestReleasePlanSubtreeHashPrune(t *testing.T) { + left := map[string]interface{}{ + "a": 1.0, + "b": "x", + "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "d": map[string]interface{}{"name": "demo"}, + } + right := map[string]interface{}{ + "a": 1.0, + "b": "x", + "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "d": map[string]interface{}{"name": "demo"}, + } + + equal, hashed := equalReleasePlanSubtreeByHash(left, right) + if !hashed { + t.Fatalf("expected hash pruning to be enabled for large maps") + } + if !equal { + t.Fatalf("expected identical subtrees to be equal") + } +} + +func TestReleasePlanSubtreeHashPruneSkipSmallNodes(t *testing.T) { + left := map[string]interface{}{"a": 1.0, "b": 2.0} + right := map[string]interface{}{"a": 1.0, "b": 2.0} + + equal, hashed := equalReleasePlanSubtreeByHash(left, right) + if hashed { + t.Fatalf("expected hash pruning to skip small maps") + } + if equal { + t.Fatalf("hash shortcut should not report equality for skipped small maps") + } +} + +func TestToGenericValueSupportsRootArrays(t *testing.T) { + value := []map[string]interface{}{ + {"id": "job-1", "name": "job-a"}, + {"id": "job-2", "name": "job-b"}, + } + + generic, err := toGenericValue(value) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items, ok := generic.([]interface{}) + if !ok { + t.Fatalf("expected array root, got %T", generic) + } + if len(items) != 2 { + t.Fatalf("unexpected item count: %d", len(items)) + } +} + +func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { + value := map[string]interface{}{ + "vars": []interface{}{ + map[string]interface{}{ + "key": "DB_PASSWORD", + "value": "secret-token", + "is_credential": true, + }, + }, + } + + sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) + vars := sanitized["vars"].([]interface{}) + item := vars[0].(map[string]interface{}) + if item["value"] != releasePlanMaskedValueDisplay { + t.Fatalf("expected credential value to be hidden") + } + if item["key"] != "DB_PASSWORD" { + t.Fatalf("expected non-sensitive fields to stay visible") + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking.go b/pkg/microservice/aslan/core/release_plan/service/masking.go new file mode 100644 index 0000000000..b6bcc4ffe0 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking.go @@ -0,0 +1,199 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanMaskedValueDisplay = "已脱敏" + releasePlanMaskedValuePrefix = "__masked__:" +) + +func createReleasePlanLog(logItem *models.ReleasePlanLog) error { + if logItem == nil { + return errors.New("nil release plan log") + } + + cloned := *logItem + cloned.Before = sanitizeReleasePlanValue(logItem.Before) + cloned.After = sanitizeReleasePlanValue(logItem.After) + return mongodb.NewReleasePlanLogColl().Create(&cloned) +} + +func sanitizeReleasePlanValue(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + return value + } + + return sanitizeReleasePlanGenericValue("", genericValue) +} + +func sanitizeReleasePlanValueForDisplay(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + if isReleasePlanMaskedStorageValue(value) { + return releasePlanMaskedValueDisplay + } + return value + } + + if hasReleasePlanRawSensitiveValue(genericValue) { + genericValue = sanitizeReleasePlanGenericValue("", genericValue) + } + return sanitizeReleasePlanDisplayGenericValue(genericValue) +} + +func sanitizeReleasePlanGenericValue(path string, value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanGenericValue(joinReleasePlanMaskPath(path, key), item) + } + if isReleasePlanSensitiveValueNode(resp) { + maskReleasePlanSensitiveValueNode(resp) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for idx, item := range typedValue { + resp = append(resp, sanitizeReleasePlanGenericValue(fmt.Sprintf("%s[%d]", path, idx), item)) + } + return resp + default: + return value + } +} + +func sanitizeReleasePlanDisplayGenericValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanDisplayGenericValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, sanitizeReleasePlanDisplayGenericValue(item)) + } + return resp + case string: + if isReleasePlanMaskedStorageValue(typedValue) { + return releasePlanMaskedValueDisplay + } + return typedValue + default: + return value + } +} + +func toReleasePlanGenericValue(value interface{}) (interface{}, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + var resp interface{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func maskReleasePlanValue(value interface{}) string { + if isReleasePlanMaskedStorageValue(value) { + if str, ok := value.(string); ok { + return str + } + } + + payload, err := json.Marshal(value) + if err != nil { + payload = []byte(fmt.Sprintf("%v", value)) + } + hash := sha256.Sum256(payload) + return releasePlanMaskedValuePrefix + hex.EncodeToString(hash[:8]) +} + +func isReleasePlanMaskedStorageValue(value interface{}) bool { + str, ok := value.(string) + return ok && strings.HasPrefix(str, releasePlanMaskedValuePrefix) +} + +func isReleasePlanSensitiveValueNode(value map[string]interface{}) bool { + if value == nil { + return false + } + return isReleasePlanSensitiveFlagTrue(value, "is_credential") || isReleasePlanSensitiveFlagTrue(value, "is_sensitive") +} + +func hasReleasePlanRawSensitiveValue(value interface{}) bool { + switch typedValue := value.(type) { + case map[string]interface{}: + if isReleasePlanSensitiveValueNode(typedValue) { + for _, key := range []string{"value", "choice_value"} { + if item, exists := typedValue[key]; exists && !isReleasePlanMaskedStorageValue(item) { + return true + } + } + } + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + case []interface{}: + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + } + return false +} + +func isReleasePlanSensitiveFlagTrue(input map[string]interface{}, key string) bool { + value, exists := input[key] + if !exists { + return false + } + flag, ok := value.(bool) + return ok && flag +} + +func maskReleasePlanSensitiveValueNode(value map[string]interface{}) { + if value == nil { + return + } + for _, key := range []string{"value", "choice_value"} { + if item, exists := value[key]; exists { + value[key] = maskReleasePlanValue(item) + } + } +} + +func joinReleasePlanMaskPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking_test.go b/pkg/microservice/aslan/core/release_plan/service/masking_test.go new file mode 100644 index 0000000000..260bd6199b --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking_test.go @@ -0,0 +1,49 @@ +package service + +import "testing" + +func TestSanitizeReleasePlanValueForDisplayMasksRawSensitiveFields(t *testing.T) { + value := map[string]interface{}{ + "key_vals": []interface{}{ + map[string]interface{}{ + "key": "DB_PASSWORD", + "value": "secret-token", + "is_credential": true, + }, + }, + } + + sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) + keyVals := sanitized["key_vals"].([]interface{}) + item := keyVals[0].(map[string]interface{}) + if item["value"] != releasePlanMaskedValueDisplay { + t.Fatalf("expected credential value to be hidden") + } +} + +func TestIsReleasePlanSensitiveValueNode(t *testing.T) { + if isReleasePlanSensitiveValueNode(map[string]interface{}{"user_id": "alice"}) { + t.Fatalf("plain user field should not be treated as sensitive") + } + if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_credential": true, "value": "secret"}) { + t.Fatalf("credential flag should be treated as sensitive") + } + if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_sensitive": true, "value": "secret"}) { + t.Fatalf("keyvault sensitive flag should be treated as sensitive") + } +} + +func TestHasReleasePlanRawSensitiveValue(t *testing.T) { + if !hasReleasePlanRawSensitiveValue(map[string]interface{}{ + "is_credential": true, + "value": "secret", + }) { + t.Fatalf("expected raw credential value to require sanitize") + } + if hasReleasePlanRawSensitiveValue(map[string]interface{}{ + "is_credential": true, + "value": maskReleasePlanValue("secret"), + }) { + t.Fatalf("expected masked credential value to skip re-sanitize") + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 2c6739d293..73e90ef046 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -158,6 +158,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning planID, err := mongodb.NewReleasePlanColl().Create(args) @@ -166,13 +167,21 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, + Version: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -216,10 +225,18 @@ type OpenAPIWorkflowReleaseJobSpec struct { } func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *OpenAPIUpdateReleasePlanWithJobsArgs) error { + approveLock := getLock(id) + approveLock.Lock() + defer approveLock.Unlock() + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id) if err != nil { return errors.Wrap(err, "get release plan error") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone release plan") + } if rawArgs.Name == "" || rawArgs.Manager == "" { return errors.New("Required parameters are missing") @@ -361,25 +378,37 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op } plan.Jobs = newJobs + plan.Version = originalPlan.Version + 1 err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { return errors.Wrap(err, "update release plan error") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + baseSnapshot, err := buildReleasePlanInputSnapshot(originalPlan) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + currentSnapshot, err := buildReleasePlanInputSnapshot(plan) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + if err := createReleasePlanVersion(plan.ID.Hex(), plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + Version: plan.Version, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + broadcastReleasePlanCollaboration(plan.ID.Hex()) return nil } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index e1f519b2fe..cb006e7e15 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -110,6 +110,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning args.InstanceCode, err = generateInstanceCode(args) @@ -131,13 +132,21 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, + Version: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -331,8 +340,19 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { return nil, errors.Wrap(err, "get release plan logs") } + sanitizedLogs := make([]*models.ReleasePlanLog, 0, len(logs)) + for _, item := range logs { + if item == nil { + continue + } + cloned := *item + cloned.Before = sanitizeReleasePlanValueForDisplay(item.Before) + cloned.After = sanitizeReleasePlanValueForDisplay(item.After) + sanitizedLogs = append(sanitizedLogs, &cloned) + } + return &GetReleasePlanLogsResponse{ - List: logs, + List: sanitizedLogs, I18N: &ReleasePlanLogI18N{ VerbI18Map: VerbI18nMap, TargetTypeI18Map: TargetTypeI18nMap, @@ -401,6 +421,10 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "get plan") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone plan") + } if plan.Status != config.ReleasePlanStatusPlanning { return errors.Errorf("plan status is %s, can not update", plan.Status) @@ -419,6 +443,21 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update") } + sectionKey, sectionName, err := releasePlanVersionSectionKeyByVerb(originalPlan, plan, args) + if err != nil { + return errors.Wrap(err, "resolve release plan section") + } + baseSnapshot, err := buildReleasePlanVersionSnapshot(originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + + plan.Version = originalPlan.Version + 1 + plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -442,21 +481,25 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update plan") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: updater.Verb(), - Before: before, - After: after, - TargetName: updater.TargetName(), - TargetType: updater.TargetType(), - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + logItem := &models.ReleasePlanLog{ + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: updater.Verb(), + Before: before, + After: after, + TargetName: updater.TargetName(), + TargetType: updater.TargetType(), + Version: plan.Version, + CreatedAt: time.Now().Unix(), + } + if err := createReleasePlanVersion(planID, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(logItem); err != nil { + log.Errorf("create release plan log error: %v", err) + } + broadcastReleasePlanCollaboration(planID) return nil } @@ -495,6 +538,47 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) } +func findReleasePlanJob(plan *models.ReleasePlan, jobID string) (*models.ReleaseJob, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job, nil + } + } + return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) +} + +func buildReleasePlanJobLogSnapshot(job *models.ReleaseJob) map[string]interface{} { + if job == nil { + return nil + } + + snapshot := map[string]interface{}{ + "type": job.Type, + "status": job.Status, + "executed_by": job.ExecutedBy, + "executed_time": job.ExecutedTime, + } + + switch job.Type { + case config.JobText: + spec := new(models.TextReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["remark"] = spec.Remark + } + case config.JobWorkflow: + spec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["workflow_status"] = spec.Status + snapshot["task_id"] = spec.TaskID + } + } + + return snapshot +} + type ExecuteReleaseJobArgs struct { ID string `json:"id"` Name string `json:"name"` @@ -531,6 +615,12 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before execute") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -543,6 +633,11 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo if err = executor.Execute(plan); err != nil { return errors.Wrap(err, "execute") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after execute") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -578,11 +673,13 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbExecute, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -630,6 +727,12 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before retry") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + retryer, err := NewReleaseJobRetryer(&RetryReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -637,11 +740,16 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg UserName: c.UserName, }, args) if err != nil { - return errors.Wrap(err, "new release job executor") + return errors.Wrap(err, "new release job retryer") } if err = retryer.Retry(plan); err != nil { - return errors.Wrap(err, "execute") + return errors.Wrap(err, "retry") + } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after retry") } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -679,11 +787,13 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbRetry, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -776,19 +886,11 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error Type: string(job.Type), } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: UserNameSystem, - Account: "", - Verb: VerbExecute, - TargetName: args.Name, - TargetType: TargetTypeReleaseJob, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + jobBefore, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, @@ -807,6 +909,12 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error return err } + jobAfter, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) + plan.UpdatedBy = UserNameSystem plan.UpdateTime = time.Now().Unix() @@ -831,6 +939,22 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error log.Error(err) return err } + + go func(jobName string, before, after map[string]interface{}) { + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbExecute, + Before: before, + After: after, + TargetName: jobName, + TargetType: TargetTypeReleaseJob, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + }(job.Name, beforeSnapshot, afterSnapshot) } } @@ -873,6 +997,12 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before skip") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + skipper, err := NewReleaseJobSkipper(&SkipReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -885,6 +1015,11 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, if err = skipper.Skip(plan); err != nil { return errors.Wrap(err, "skip") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after skip") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -905,6 +1040,8 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } else { plan.SuccessTime = time.Now().Unix() } + + sendWebhook = true } if err = mongodb.NewReleasePlanColl().UpdateByID(ctx, planID, plan); err != nil { @@ -918,11 +1055,13 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbSkip, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -954,7 +1093,9 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Errorf("only manager can update plan status") } - if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], config.ReleasePlanStatus(targetStatus)) { + newStatus := config.ReleasePlanStatus(targetStatus) + oldStatus := plan.Status + if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], newStatus) { return errors.Errorf("can't convert plan status %s to %s", plan.Status, targetStatus) } @@ -963,8 +1104,6 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Wrap(err, "get user") } - detail := "" - sendWebhook := false hookSetting, err := mongodb.NewSystemSettingColl().GetReleasePlanHookSetting() if err != nil { @@ -989,14 +1128,14 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is config.ReleasePlanStatusWaitForExecuteExternalCheckFailed, config.ReleasePlanStatusWaitForAllDoneExternalCheck, config.ReleasePlanStatusWaitForAllDoneExternalCheckFailed: - if config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusPlanning && config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusCancel { + if newStatus != config.ReleasePlanStatusPlanning && newStatus != config.ReleasePlanStatusCancel { return fmt.Errorf("can't update status, current status: %s", plan.Status) } } - plan.Status = config.ReleasePlanStatus(targetStatus) + plan.Status = newStatus // target status check and update - switch config.ReleasePlanStatus(targetStatus) { + switch newStatus { case config.ReleasePlanStatusPlanning: for _, job := range plan.Jobs { job.LastStatus = job.Status @@ -1116,6 +1255,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is if err := upsertReleasePlanCron(plan.ID.Hex(), plan.Name, plan.Index, plan.Status, plan.ScheduleExecuteTime); err != nil { return errors.Wrap(err, "upsert release plan cron") } + updatedStatus := plan.Status + detail := fmt.Sprintf("状态从 %s 变更为 %s", oldStatus, updatedStatus) if sendWebhook { if err := sendReleasePlanHook(plan, hookSetting); err != nil { @@ -1124,7 +1265,7 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, @@ -1132,8 +1273,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: detail, - Before: plan.Status, - After: targetStatus, + Before: oldStatus, + After: updatedStatus, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -1204,16 +1345,18 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) plan.Approval.Status = config.StatusPassed } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: planID, Username: UserNameSystem, + Account: "", Verb: VerbUpdate, TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusExecuting @@ -1235,11 +1378,19 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: planID, - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: TargetTypeReleasePlanStatus, + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied @@ -1261,7 +1412,7 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }() diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go new file mode 100644 index 0000000000..fa5581097f --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -0,0 +1,268 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" +) + +const ( + releasePlanVersionSectionPlan = "plan" + releasePlanVersionSectionMetadata = "metadata" + releasePlanVersionSectionApproval = "approval" + releasePlanVersionSectionJobsOrder = "jobs_order" + releasePlanVersionSectionJobPrefix = "job:" +) + +func releasePlanVersionSectionName(sectionKey, fallbackName string) string { + switch { + case sectionKey == releasePlanVersionSectionPlan: + return "发布计划" + case sectionKey == releasePlanVersionSectionMetadata: + return "基础信息" + case sectionKey == releasePlanVersionSectionApproval: + return "审批配置" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "发布内容顺序" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + if fallbackName != "" { + return fallbackName + } + return "发布内容" + default: + return fallbackName + } +} + +func releasePlanVersionSectionGroupType(sectionKey string) string { + switch { + case sectionKey == releasePlanVersionSectionMetadata: + return "metadata" + case sectionKey == releasePlanVersionSectionApproval: + return "approval" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "jobs_order" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + return "job" + default: + return "plan" + } +} + +func cloneReleasePlan(plan *models.ReleasePlan) (*models.ReleasePlan, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + + payload, err := json.Marshal(plan) + if err != nil { + return nil, err + } + + resp := new(models.ReleasePlan) + if err := json.Unmarshal(payload, resp); err != nil { + return nil, err + } + return resp, nil +} + +func releasePlanVersionSectionKeyByVerb(planBefore, planAfter *models.ReleasePlan, args *UpdateReleasePlanArgs) (string, string, error) { + if args == nil { + return releasePlanVersionSectionPlan, "发布计划", nil + } + + switch args.Verb { + case VerbUpdateName, VerbUpdateDesc, VerbUpdateTimeRange, VerbUpdateScheduleExecuteTime, VerbUpdateManager, VerbUpdateJiraSprint: + return releasePlanVersionSectionMetadata, "基础信息", nil + case VerbUpdateApproval, VerbDeleteApproval: + return releasePlanVersionSectionApproval, "审批配置", nil + case VerbReorderReleaseJob: + return releasePlanVersionSectionJobsOrder, "发布内容顺序", nil + case VerbUpdateReleaseJob, VerbDeleteReleaseJob: + jobID, _ := extractReleasePlanJobID(args.Spec) + if jobID == "" { + return "", "", errors.New("missing release job id") + } + jobName := releasePlanVersionSectionJobName(planAfter, jobID) + if jobName == "" { + jobName = releasePlanVersionSectionJobName(planBefore, jobID) + } + return releasePlanVersionSectionJobPrefix + jobID, jobName, nil + case VerbCreateReleaseJob: + createdJob := findCreatedReleasePlanJob(planBefore, planAfter) + if createdJob == nil { + return "", "", errors.New("failed to locate created release job") + } + return releasePlanVersionSectionJobPrefix + createdJob.ID, createdJob.Name, nil + default: + return releasePlanVersionSectionPlan, "发布计划", nil + } +} + +func extractReleasePlanJobID(spec interface{}) (string, error) { + if spec == nil { + return "", nil + } + payload, err := json.Marshal(spec) + if err != nil { + return "", err + } + resp := struct { + ID string `json:"id"` + }{} + if err := json.Unmarshal(payload, &resp); err != nil { + return "", err + } + return resp.ID, nil +} + +func releasePlanVersionSectionJobName(plan *models.ReleasePlan, jobID string) string { + if plan == nil { + return "" + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job.Name + } + } + return "" +} + +func findCreatedReleasePlanJob(planBefore, planAfter *models.ReleasePlan) *models.ReleaseJob { + if planAfter == nil { + return nil + } + beforeJobIDs := make(map[string]struct{}, len(planBefore.Jobs)) + if planBefore != nil { + for _, job := range planBefore.Jobs { + beforeJobIDs[job.ID] = struct{}{} + } + } + for _, job := range planAfter.Jobs { + if _, exists := beforeJobIDs[job.ID]; !exists { + return job + } + } + return nil +} + +func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string) (interface{}, error) { + if plan == nil { + return nil, nil + } + + switch { + case sectionKey == releasePlanVersionSectionPlan: + return buildReleasePlanInputSnapshot(plan) + case sectionKey == releasePlanVersionSectionMetadata: + return buildReleasePlanMetadataSnapshot(plan), nil + case sectionKey == releasePlanVersionSectionApproval: + return sanitizeReleasePlanValue(plan.Approval), nil + case sectionKey == releasePlanVersionSectionJobsOrder: + return buildReleasePlanJobsOrderSnapshot(plan), nil + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix) + job, err := findReleasePlanJob(plan, jobID) + if err != nil { + return nil, nil + } + return buildReleasePlanJobInputSnapshot(job) + default: + return nil, errors.Errorf("unsupported release plan version section key: %s", sectionKey) + } +} + +func buildReleasePlanInputSnapshot(plan *models.ReleasePlan) (interface{}, error) { + resp := map[string]interface{}{ + "metadata": buildReleasePlanMetadataSnapshot(plan), + "approval": sanitizeReleasePlanValue(plan.Approval), + "jobs": make([]interface{}, 0, len(plan.Jobs)), + } + for _, job := range plan.Jobs { + snapshot, err := buildReleasePlanJobInputSnapshot(job) + if err != nil { + return nil, err + } + resp["jobs"] = append(resp["jobs"].([]interface{}), snapshot) + } + return resp, nil +} + +func buildReleasePlanMetadataSnapshot(plan *models.ReleasePlan) map[string]interface{} { + if plan == nil { + return nil + } + return map[string]interface{}{ + "name": plan.Name, + "manager": plan.Manager, + "manager_id": plan.ManagerID, + "start_time": plan.StartTime, + "end_time": plan.EndTime, + "schedule_execute_time": plan.ScheduleExecuteTime, + "description": plan.Description, + "jira_sprint_association": sanitizeReleasePlanValue(plan.JiraSprintAssociation), + } +} + +func buildReleasePlanJobsOrderSnapshot(plan *models.ReleasePlan) []interface{} { + resp := make([]interface{}, 0) + if plan == nil { + return resp + } + for _, job := range plan.Jobs { + resp = append(resp, map[string]interface{}{ + "id": job.ID, + "name": job.Name, + }) + } + return resp +} + +func buildReleasePlanJobInputSnapshot(job *models.ReleaseJob) (interface{}, error) { + if job == nil { + return nil, nil + } + + spec, err := buildReleasePlanJobInputSpec(job.Type, job.Spec) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "id": job.ID, + "name": job.Name, + "manager": job.Manager, + "manager_id": job.ManagerID, + "type": job.Type, + "spec": spec, + }, nil +} + +func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interface{}) (interface{}, error) { + switch jobType { + case config.JobText: + inputSpec := new(models.TextReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(inputSpec), nil + case config.JobWorkflow: + inputSpec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(map[string]interface{}{ + "workflow": inputSpec.Workflow, + }), nil + default: + return sanitizeReleasePlanValue(spec), nil + } +} + +func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { + return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey) +} diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index 8939e7355d..fbb9776673 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -83,6 +83,7 @@ var VerbI18nMap = map[string]string{ VerbUpdate: "Update", VerbDelete: "Delete", VerbExecute: "Execute", + VerbRetry: "Retry", VerbSkip: "Skip", } @@ -221,12 +222,18 @@ func NewTimeRangeUpdater(args *UpdateReleasePlanArgs) (*TimeRangeUpdater, error) return &updater, nil } +func formatReleasePlanDateTime(timestamp int64) string { + if timestamp == 0 { + return "未设置" + } + return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") +} + func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - format := "2006-01-02 15:04:05" - before = fmt.Sprintf("%s-%s", time.Unix(plan.StartTime, 0).Format(format), - time.Unix(plan.EndTime, 0).Format(format)) - after = fmt.Sprintf("%s-%s", time.Unix(u.StartTime, 0).Format(format), - time.Unix(u.EndTime, 0).Format(format)) + before = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(plan.StartTime), + formatReleasePlanDateTime(plan.EndTime)) + after = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(u.StartTime), + formatReleasePlanDateTime(u.EndTime)) plan.StartTime = u.StartTime plan.EndTime = u.EndTime return @@ -368,7 +375,11 @@ func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter if job.Type != u.Type { return nil, nil, fmt.Errorf("job type cannot be changed") } - before, after = job, u + beforeJob := new(models.ReleaseJob) + if err := models.IToi(job, beforeJob); err != nil { + return nil, nil, errors.Wrap(err, "clone release job before update") + } + before, after = beforeJob, u job.Name = u.Name job.Manager = u.Manager job.ManagerID = u.ManagerID @@ -420,6 +431,8 @@ func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter for i, job := range plan.Jobs { if job.ID == u.ID { u.name = job.Name + before = job + after = nil plan.Jobs = append(plan.Jobs[:i], plan.Jobs[i+1:]...) return } @@ -655,9 +668,8 @@ func NewScheduleExecuteTimeUpdater(args *UpdateReleasePlanArgs) (*ScheduleExecut } func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - format := "2006-01-02 15:04:05" - before = time.Unix(plan.ScheduleExecuteTime, 0).Format(format) - after = time.Unix(u.ScheduleExecuteTime, 0).Format(format) + before = formatReleasePlanDateTime(plan.ScheduleExecuteTime) + after = formatReleasePlanDateTime(u.ScheduleExecuteTime) plan.ScheduleExecuteTime = u.ScheduleExecuteTime return } diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go new file mode 100644 index 0000000000..0373dde909 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -0,0 +1,39 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "time" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +func createReleasePlanVersion(planID string, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { + return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ + PlanID: planID, + Version: version, + Operator: operator, + Account: account, + SectionKey: sectionKey, + SectionName: sectionName, + Verb: verb, + BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot), + Snapshot: sanitizeReleasePlanValue(snapshot), + CreatedAt: time.Now().Unix(), + }) +} diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index d18c6f1087..9e297a3bec 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -165,6 +165,7 @@ func WatchApproval() { }) if err != nil { log.Errorf("list approval workflow error: %v", err) + releasePlanApprovalLock.Unlock() continue } for _, plan := range list { @@ -229,16 +230,18 @@ func updatePlanApproval(plan *models.ReleasePlan) error { return errors.Errorf("update plan %s approval error: %v", plan.Name, err) } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: plan.ID.Hex(), Username: UserNameSystem, + Account: "", Verb: VerbUpdate, TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } @@ -260,11 +263,19 @@ func updatePlanApproval(plan *models.ReleasePlan) error { sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: plan.ID.Hex(), + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: TargetTypeReleasePlanStatus, + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied plan.ApprovalTime = time.Now().Unix() @@ -285,7 +296,7 @@ func updatePlanApproval(plan *models.ReleasePlan) error { return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }()