diff --git a/api/cluster/ClusterRestHandler.go b/api/cluster/ClusterRestHandler.go index 86abea7efd..9b72614f61 100644 --- a/api/cluster/ClusterRestHandler.go +++ b/api/cluster/ClusterRestHandler.go @@ -27,13 +27,13 @@ import ( "time" "github.com/devtron-labs/devtron/api/restHandler/common" + "github.com/devtron-labs/devtron/pkg/cluster" delete2 "github.com/devtron-labs/devtron/pkg/delete" + "github.com/devtron-labs/devtron/pkg/user" "github.com/devtron-labs/devtron/pkg/user/casbin" util2 "github.com/devtron-labs/devtron/util" "github.com/devtron-labs/devtron/util/argo" - - "github.com/devtron-labs/devtron/pkg/cluster" - "github.com/devtron-labs/devtron/pkg/user" + "github.com/go-pg/pg" "github.com/gorilla/mux" "go.uber.org/zap" "gopkg.in/go-playground/validator.v9" @@ -44,10 +44,10 @@ const CLUSTER_DELETE_SUCCESS_RESP = "Cluster deleted successfully." type ClusterRestHandler interface { Save(w http.ResponseWriter, r *http.Request) FindAll(w http.ResponseWriter, r *http.Request) - FindById(w http.ResponseWriter, r *http.Request) + FindNoteByClusterId(w http.ResponseWriter, r *http.Request) Update(w http.ResponseWriter, r *http.Request) - + UpdateClusterNote(w http.ResponseWriter, r *http.Request) FindAllForAutoComplete(w http.ResponseWriter, r *http.Request) DeleteCluster(w http.ResponseWriter, r *http.Request) GetClusterNamespaces(w http.ResponseWriter, r *http.Request) @@ -56,30 +56,39 @@ type ClusterRestHandler interface { } type ClusterRestHandlerImpl struct { - clusterService cluster.ClusterService - logger *zap.SugaredLogger - userService user.UserService - validator *validator.Validate - enforcer casbin.Enforcer - deleteService delete2.DeleteService - argoUserService argo.ArgoUserService + clusterService cluster.ClusterService + clusterNoteService cluster.ClusterNoteService + clusterDescriptionService cluster.ClusterDescriptionService + logger *zap.SugaredLogger + userService user.UserService + validator *validator.Validate + enforcer casbin.Enforcer + deleteService delete2.DeleteService + argoUserService argo.ArgoUserService + environmentService cluster.EnvironmentService } func NewClusterRestHandlerImpl(clusterService cluster.ClusterService, + clusterNoteService cluster.ClusterNoteService, + clusterDescriptionService cluster.ClusterDescriptionService, logger *zap.SugaredLogger, userService user.UserService, validator *validator.Validate, enforcer casbin.Enforcer, deleteService delete2.DeleteService, - argoUserService argo.ArgoUserService) *ClusterRestHandlerImpl { + argoUserService argo.ArgoUserService, + environmentService cluster.EnvironmentService) *ClusterRestHandlerImpl { return &ClusterRestHandlerImpl{ - clusterService: clusterService, - logger: logger, - userService: userService, - validator: validator, - enforcer: enforcer, - deleteService: deleteService, - argoUserService: argoUserService, + clusterService: clusterService, + clusterNoteService: clusterNoteService, + clusterDescriptionService: clusterDescriptionService, + logger: logger, + userService: userService, + validator: validator, + enforcer: enforcer, + deleteService: deleteService, + argoUserService: argoUserService, + environmentService: environmentService, } } @@ -200,6 +209,42 @@ func (impl ClusterRestHandlerImpl) FindById(w http.ResponseWriter, r *http.Reque common.WriteJsonResp(w, err, bean, http.StatusOK) } +func (impl ClusterRestHandlerImpl) FindNoteByClusterId(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + i, err := strconv.Atoi(id) + if err != nil { + impl.logger.Errorw("request err, FindById", "error", err, "clusterId", id) + common.WriteJsonResp(w, err, nil, http.StatusBadRequest) + return + } + bean, err := impl.clusterDescriptionService.FindByClusterIdWithClusterDetails(i) + if err != nil { + if err == pg.ErrNoRows { + impl.logger.Errorw("cluster not found, FindById", "err", err, "clusterId", id) + common.WriteJsonResp(w, errors.New("invalid cluster id"), nil, http.StatusNotFound) + return + } + impl.logger.Errorw("service err, FindById", "err", err, "clusterId", id) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + // RBAC enforcer applying + token := r.Header.Get("token") + authenticated, err := impl.CheckRbacForClusterDetails(bean.ClusterId, token) + if err != nil { + impl.logger.Errorw("error in checking rbac for cluster", "err", err, "clusterId", bean.ClusterId) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + if !authenticated { + common.WriteJsonResp(w, errors.New("unauthorized"), nil, http.StatusForbidden) + return + } + //RBAC enforcer Ends + common.WriteJsonResp(w, err, bean, http.StatusOK) +} + func (impl ClusterRestHandlerImpl) Update(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("token") decoder := json.NewDecoder(r.Body) @@ -261,6 +306,76 @@ func (impl ClusterRestHandlerImpl) Update(w http.ResponseWriter, r *http.Request common.WriteJsonResp(w, err, bean, http.StatusOK) } +func (impl ClusterRestHandlerImpl) UpdateClusterNote(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("token") + decoder := json.NewDecoder(r.Body) + userId, err := impl.userService.GetLoggedInUser(r) + if userId == 0 || err != nil { + impl.logger.Errorw("service err, Update", "error", err, "userId", userId) + common.WriteJsonResp(w, err, "Unauthorized User", http.StatusUnauthorized) + return + } + var bean cluster.ClusterNoteBean + err = decoder.Decode(&bean) + if err != nil { + impl.logger.Errorw("request err, Update", "error", err, "payload", bean) + common.WriteJsonResp(w, err, nil, http.StatusBadRequest) + return + } + impl.logger.Infow("request payload, Update", "payload", bean) + err = impl.validator.Struct(bean) + if err != nil { + impl.logger.Errorw("validate err, Update", "error", err, "payload", bean) + common.WriteJsonResp(w, err, nil, http.StatusBadRequest) + return + } + clusterDescription, err := impl.clusterDescriptionService.FindByClusterIdWithClusterDetails(bean.ClusterId) + if err != nil { + impl.logger.Errorw("service err, FindById", "err", err, "clusterId", bean.ClusterId) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + // RBAC enforcer applying + if ok := impl.enforcer.Enforce(token, casbin.ResourceCluster, casbin.ActionUpdate, strings.ToLower(clusterDescription.ClusterName)); !ok { + common.WriteJsonResp(w, errors.New("unauthorized"), nil, http.StatusForbidden) + return + } + // RBAC enforcer ends + + _, err = impl.clusterNoteService.Update(&bean, userId) + if err == pg.ErrNoRows { + _, err = impl.clusterNoteService.Save(&bean, userId) + if err != nil { + impl.logger.Errorw("service err, Save", "error", err, "payload", bean) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + } + if err != nil { + impl.logger.Errorw("service err, Update", "error", err, "payload", bean) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + userInfo, err := impl.userService.GetById(bean.UpdatedBy) + if err != nil { + impl.logger.Errorw("user service err, FindById", "err", err, "userId", bean.UpdatedBy) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + clusterNoteResponseBean := &cluster.ClusterNoteResponseBean{ + Id: bean.Id, + Description: bean.Description, + UpdatedOn: bean.UpdatedOn, + UpdatedBy: userInfo.EmailId, + } + if err != nil { + impl.logger.Errorw("cluster note service err, Update", "error", err, "payload", bean) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + common.WriteJsonResp(w, err, clusterNoteResponseBean, http.StatusOK) +} + func (impl ClusterRestHandlerImpl) FindAllForAutoComplete(w http.ResponseWriter, r *http.Request) { start := time.Now() clusterList, err := impl.clusterService.FindAllForAutoComplete() @@ -422,3 +537,43 @@ func (impl ClusterRestHandlerImpl) FindAllForClusterPermission(w http.ResponseWr } common.WriteJsonResp(w, err, clusterList, http.StatusOK) } + +func (impl *ClusterRestHandlerImpl) CheckRbacForClusterDetails(clusterId int, token string) (authenticated bool, err error) { + //getting all environments for this cluster + envs, err := impl.environmentService.GetByClusterId(clusterId) + if err != nil { + impl.logger.Errorw("error in getting environments by clusterId", "err", err, "clusterId", clusterId) + return false, err + } + if len(envs) == 0 { + if ok := impl.enforcer.Enforce(token, casbin.ResourceGlobal, casbin.ActionGet, "*"); !ok { + return false, nil + } + return true, nil + } + emailId, err := impl.userService.GetEmailFromToken(token) + if err != nil { + impl.logger.Errorw("error in getting emailId from token", "err", err) + return false, err + } + + var envIdentifierList []string + envIdentifierMap := make(map[string]bool) + for _, env := range envs { + envIdentifier := strings.ToLower(env.EnvironmentIdentifier) + envIdentifierList = append(envIdentifierList, envIdentifier) + envIdentifierMap[envIdentifier] = true + } + if len(envIdentifierList) == 0 { + return false, errors.New("environment identifier list for rbac batch enforcing contains zero environments") + } + // RBAC enforcer applying + rbacResultMap := impl.enforcer.EnforceByEmailInBatch(emailId, casbin.ResourceGlobalEnvironment, casbin.ActionGet, envIdentifierList) + for envIdentifier, _ := range envIdentifierMap { + if rbacResultMap[envIdentifier] { + //if user has view permission to even one environment of this cluster, authorise the request + return true, nil + } + } + return false, nil +} diff --git a/api/cluster/ClusterRouter.go b/api/cluster/ClusterRouter.go index 400a0c1792..c26f3e850e 100644 --- a/api/cluster/ClusterRouter.go +++ b/api/cluster/ClusterRouter.go @@ -45,6 +45,11 @@ func (impl ClusterRouterImpl) InitClusterRouter(clusterRouter *mux.Router) { Queries("id", "{id}"). HandlerFunc(impl.clusterRestHandler.FindById) + clusterRouter.Path("/description"). + Methods("GET"). + Queries("id", "{id}"). + HandlerFunc(impl.clusterRestHandler.FindNoteByClusterId) + clusterRouter.Path(""). Methods("GET"). HandlerFunc(impl.clusterRestHandler.FindAll) @@ -53,6 +58,10 @@ func (impl ClusterRouterImpl) InitClusterRouter(clusterRouter *mux.Router) { Methods("PUT"). HandlerFunc(impl.clusterRestHandler.Update) + clusterRouter.Path("/description/note"). + Methods("PUT"). + HandlerFunc(impl.clusterRestHandler.UpdateClusterNote) + clusterRouter.Path("/autocomplete"). Methods("GET"). HandlerFunc(impl.clusterRestHandler.FindAllForAutoComplete) diff --git a/api/cluster/wire_cluster.go b/api/cluster/wire_cluster.go index 1ada47d0bc..30f86de636 100644 --- a/api/cluster/wire_cluster.go +++ b/api/cluster/wire_cluster.go @@ -13,6 +13,20 @@ var ClusterWireSet = wire.NewSet( wire.Bind(new(repository.ClusterRepository), new(*repository.ClusterRepositoryImpl)), cluster.NewClusterServiceImplExtended, wire.Bind(new(cluster.ClusterService), new(*cluster.ClusterServiceImplExtended)), + + repository.NewClusterDescriptionRepositoryImpl, + wire.Bind(new(repository.ClusterDescriptionRepository), new(*repository.ClusterDescriptionRepositoryImpl)), + repository.NewClusterNoteHistoryRepositoryImpl, + wire.Bind(new(repository.ClusterNoteHistoryRepository), new(*repository.ClusterNoteHistoryRepositoryImpl)), + repository.NewClusterNoteRepositoryImpl, + wire.Bind(new(repository.ClusterNoteRepository), new(*repository.ClusterNoteRepositoryImpl)), + cluster.NewClusterNoteHistoryServiceImpl, + wire.Bind(new(cluster.ClusterNoteHistoryService), new(*cluster.ClusterNoteHistoryServiceImpl)), + cluster.NewClusterNoteServiceImpl, + wire.Bind(new(cluster.ClusterNoteService), new(*cluster.ClusterNoteServiceImpl)), + cluster.NewClusterDescriptionServiceImpl, + wire.Bind(new(cluster.ClusterDescriptionService), new(*cluster.ClusterDescriptionServiceImpl)), + NewClusterRestHandlerImpl, wire.Bind(new(ClusterRestHandler), new(*ClusterRestHandlerImpl)), NewClusterRouterImpl, @@ -28,12 +42,26 @@ var ClusterWireSet = wire.NewSet( wire.Bind(new(EnvironmentRouter), new(*EnvironmentRouterImpl)), ) -//minimal wire to be used with EA +// minimal wire to be used with EA var ClusterWireSetEa = wire.NewSet( repository.NewClusterRepositoryImpl, wire.Bind(new(repository.ClusterRepository), new(*repository.ClusterRepositoryImpl)), cluster.NewClusterServiceImpl, wire.Bind(new(cluster.ClusterService), new(*cluster.ClusterServiceImpl)), + + repository.NewClusterDescriptionRepositoryImpl, + wire.Bind(new(repository.ClusterDescriptionRepository), new(*repository.ClusterDescriptionRepositoryImpl)), + repository.NewClusterNoteHistoryRepositoryImpl, + wire.Bind(new(repository.ClusterNoteHistoryRepository), new(*repository.ClusterNoteHistoryRepositoryImpl)), + repository.NewClusterNoteRepositoryImpl, + wire.Bind(new(repository.ClusterNoteRepository), new(*repository.ClusterNoteRepositoryImpl)), + cluster.NewClusterNoteHistoryServiceImpl, + wire.Bind(new(cluster.ClusterNoteHistoryService), new(*cluster.ClusterNoteHistoryServiceImpl)), + cluster.NewClusterNoteServiceImpl, + wire.Bind(new(cluster.ClusterNoteService), new(*cluster.ClusterNoteServiceImpl)), + cluster.NewClusterDescriptionServiceImpl, + wire.Bind(new(cluster.ClusterDescriptionService), new(*cluster.ClusterDescriptionServiceImpl)), + NewClusterRestHandlerImpl, wire.Bind(new(ClusterRestHandler), new(*ClusterRestHandlerImpl)), NewClusterRouterImpl, diff --git a/cmd/external-app/wire_gen.go b/cmd/external-app/wire_gen.go index d1063c7c5b..df9f6f525f 100644 --- a/cmd/external-app/wire_gen.go +++ b/cmd/external-app/wire_gen.go @@ -177,11 +177,17 @@ func InitializeApp() (*App, error) { roleGroupServiceImpl := user.NewRoleGroupServiceImpl(userAuthRepositoryImpl, sugaredLogger, userRepositoryImpl, roleGroupRepositoryImpl, userCommonServiceImpl) userRestHandlerImpl := user2.NewUserRestHandlerImpl(userServiceImpl, validate, sugaredLogger, enforcerImpl, roleGroupServiceImpl, userCommonServiceImpl) userRouterImpl := user2.NewUserRouterImpl(userRestHandlerImpl) + clusterNoteRepositoryImpl := repository2.NewClusterNoteRepositoryImpl(db, sugaredLogger) + clusterNoteHistoryRepositoryImpl := repository2.NewClusterNoteHistoryRepositoryImpl(db, sugaredLogger) + clusterNoteHistoryServiceImpl := cluster.NewClusterNoteHistoryServiceImpl(clusterNoteHistoryRepositoryImpl, sugaredLogger) + clusterNoteServiceImpl := cluster.NewClusterNoteServiceImpl(clusterNoteRepositoryImpl, clusterNoteHistoryServiceImpl, sugaredLogger) + clusterDescriptionRepositoryImpl := repository2.NewClusterDescriptionRepositoryImpl(db, sugaredLogger) + clusterDescriptionServiceImpl := cluster.NewClusterDescriptionServiceImpl(clusterDescriptionRepositoryImpl, userRepositoryImpl, sugaredLogger) helmUserServiceImpl, err := argo.NewHelmUserServiceImpl(sugaredLogger) if err != nil { return nil, err } - clusterRestHandlerImpl := cluster2.NewClusterRestHandlerImpl(clusterServiceImpl, sugaredLogger, userServiceImpl, validate, enforcerImpl, deleteServiceImpl, helmUserServiceImpl) + clusterRestHandlerImpl := cluster2.NewClusterRestHandlerImpl(clusterServiceImpl, clusterNoteServiceImpl, clusterDescriptionServiceImpl, sugaredLogger, userServiceImpl, validate, enforcerImpl, deleteServiceImpl, helmUserServiceImpl, environmentServiceImpl) clusterRouterImpl := cluster2.NewClusterRouterImpl(clusterRestHandlerImpl) dashboardConfig, err := dashboard.GetConfig() if err != nil { diff --git a/pkg/cluster/ClusterDescriptionService.go b/pkg/cluster/ClusterDescriptionService.go new file mode 100644 index 0000000000..c80971703b --- /dev/null +++ b/pkg/cluster/ClusterDescriptionService.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2020 Devtron Labs + * + * 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 cluster + +import ( + repository2 "github.com/devtron-labs/devtron/pkg/user/repository" + "time" + + "github.com/devtron-labs/devtron/pkg/cluster/repository" + "github.com/go-pg/pg" + "go.uber.org/zap" +) + +type ClusterDescriptionBean struct { + ClusterId int `json:"clusterId" validate:"number"` + ClusterName string `json:"clusterName" validate:"required"` + ClusterCreatedBy string `json:"clusterCreatedBy" validate:"number"` + ClusterCreatedOn time.Time `json:"clusterCreatedOn" validate:"required"` + ClusterNote *ClusterNoteResponseBean `json:"clusterNote,omitempty"` +} + +type ClusterDescriptionService interface { + FindByClusterIdWithClusterDetails(id int) (*ClusterDescriptionBean, error) +} + +type ClusterDescriptionServiceImpl struct { + clusterDescriptionRepository repository.ClusterDescriptionRepository + userRepository repository2.UserRepository + logger *zap.SugaredLogger +} + +func NewClusterDescriptionServiceImpl(repository repository.ClusterDescriptionRepository, userRepository repository2.UserRepository, logger *zap.SugaredLogger) *ClusterDescriptionServiceImpl { + clusterDescriptionService := &ClusterDescriptionServiceImpl{ + clusterDescriptionRepository: repository, + userRepository: userRepository, + logger: logger, + } + return clusterDescriptionService +} + +func (impl *ClusterDescriptionServiceImpl) FindByClusterIdWithClusterDetails(id int) (*ClusterDescriptionBean, error) { + model, err := impl.clusterDescriptionRepository.FindByClusterIdWithClusterDetails(id) + if err != nil { + return nil, err + } + clusterCreatedByUser, err := impl.userRepository.GetById(model.ClusterCreatedBy) + if err != nil { + impl.logger.Errorw("error in fetching user", "error", err) + return nil, err + } + noteUpdatedByUser, err := impl.userRepository.GetById(model.UpdatedBy) + if err != nil && err != pg.ErrNoRows { + impl.logger.Errorw("error in fetching user", "error", err) + return nil, err + } + bean := &ClusterDescriptionBean{ + ClusterId: model.ClusterId, + ClusterName: model.ClusterName, + ClusterCreatedBy: clusterCreatedByUser.EmailId, + ClusterCreatedOn: model.ClusterCreatedOn, + } + if model.NoteId > 0 { + clusterNote := &ClusterNoteResponseBean{ + Id: model.NoteId, + Description: model.Description, + UpdatedBy: noteUpdatedByUser.EmailId, + UpdatedOn: model.UpdatedOn, + } + bean.ClusterNote = clusterNote + } + return bean, nil +} diff --git a/pkg/cluster/ClusterNoteHistoryService.go b/pkg/cluster/ClusterNoteHistoryService.go new file mode 100644 index 0000000000..54b88e8a1f --- /dev/null +++ b/pkg/cluster/ClusterNoteHistoryService.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2020 Devtron Labs + * + * 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 cluster + +import ( + "time" + + "github.com/devtron-labs/devtron/pkg/cluster/repository" + "go.uber.org/zap" +) + +type ClusterNoteHistoryBean struct { + Id int `json:"id" validate:"number"` + NoteId int `json:"noteId" validate:"required"` + Description string `json:"description" validate:"required"` + CreatedBy int32 `json:"createdBy" validate:"number"` + CreatedOn time.Time `json:"createdOn" validate:"required"` +} + +type ClusterNoteHistoryService interface { + Save(bean *ClusterNoteHistoryBean, userId int32) (*ClusterNoteHistoryBean, error) +} + +type ClusterNoteHistoryServiceImpl struct { + clusterNoteHistoryRepository repository.ClusterNoteHistoryRepository + logger *zap.SugaredLogger +} + +func NewClusterNoteHistoryServiceImpl(repositoryHistory repository.ClusterNoteHistoryRepository, logger *zap.SugaredLogger) *ClusterNoteHistoryServiceImpl { + clusterNoteHistoryService := &ClusterNoteHistoryServiceImpl{ + clusterNoteHistoryRepository: repositoryHistory, + logger: logger, + } + return clusterNoteHistoryService +} + +func (impl *ClusterNoteHistoryServiceImpl) Save(bean *ClusterNoteHistoryBean, userId int32) (*ClusterNoteHistoryBean, error) { + clusterAudit := &repository.ClusterNoteHistory{ + NoteId: bean.NoteId, + Description: bean.Description, + } + clusterAudit.CreatedBy = bean.CreatedBy + clusterAudit.CreatedOn = bean.CreatedOn + clusterAudit.UpdatedBy = userId + clusterAudit.UpdatedOn = time.Now() + err := impl.clusterNoteHistoryRepository.SaveHistory(clusterAudit) + if err != nil { + impl.logger.Errorw("cluster note history save failed in db", "id", bean.NoteId) + return nil, err + } + return bean, err +} diff --git a/pkg/cluster/ClusterNoteService.go b/pkg/cluster/ClusterNoteService.go new file mode 100644 index 0000000000..e1b6c54349 --- /dev/null +++ b/pkg/cluster/ClusterNoteService.go @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2020 Devtron Labs + * + * 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 cluster + +import ( + "fmt" + "time" + + "github.com/devtron-labs/devtron/internal/constants" + "github.com/devtron-labs/devtron/internal/util" + "github.com/devtron-labs/devtron/pkg/cluster/repository" + "github.com/go-pg/pg" + "go.uber.org/zap" +) + +type ClusterNoteBean struct { + Id int `json:"id" validate:"number"` + ClusterId int `json:"clusterId" validate:"required"` + Description string `json:"description" validate:"required"` + UpdatedBy int32 `json:"updatedBy"` + UpdatedOn time.Time `json:"updatedOn"` +} + +type ClusterNoteResponseBean struct { + Id int `json:"id" validate:"number"` + Description string `json:"description" validate:"required"` + UpdatedBy string `json:"updatedBy"` + UpdatedOn time.Time `json:"updatedOn"` +} + +type ClusterNoteService interface { + Save(bean *ClusterNoteBean, userId int32) (*ClusterNoteBean, error) + Update(bean *ClusterNoteBean, userId int32) (*ClusterNoteBean, error) +} + +type ClusterNoteServiceImpl struct { + clusterNoteRepository repository.ClusterNoteRepository + clusterNoteHistoryService ClusterNoteHistoryService + logger *zap.SugaredLogger +} + +func NewClusterNoteServiceImpl(repository repository.ClusterNoteRepository, clusterNoteHistoryService ClusterNoteHistoryService, logger *zap.SugaredLogger) *ClusterNoteServiceImpl { + clusterNoteService := &ClusterNoteServiceImpl{ + clusterNoteRepository: repository, + clusterNoteHistoryService: clusterNoteHistoryService, + logger: logger, + } + return clusterNoteService +} + +func (impl *ClusterNoteServiceImpl) Save(bean *ClusterNoteBean, userId int32) (*ClusterNoteBean, error) { + existingModel, err := impl.clusterNoteRepository.FindByClusterId(bean.ClusterId) + if err != nil && err != pg.ErrNoRows { + impl.logger.Error(err) + return nil, err + } + if existingModel.Id > 0 { + impl.logger.Errorw("error on fetching cluster, duplicate", "id", bean.ClusterId) + return nil, fmt.Errorf("cluster note already exists") + } + + model := &repository.ClusterNote{ + ClusterId: bean.ClusterId, + Description: bean.Description, + } + + model.CreatedBy = userId + model.UpdatedBy = userId + model.CreatedOn = time.Now() + model.UpdatedOn = time.Now() + + err = impl.clusterNoteRepository.Save(model) + if err != nil { + impl.logger.Errorw("error in saving cluster note in db", "err", err) + err = &util.ApiError{ + Code: constants.ClusterCreateDBFailed, + InternalMessage: "cluster note creation failed in db", + UserMessage: fmt.Sprintf("requested by %d", userId), + } + return nil, err + } + bean.Id = model.Id + bean.UpdatedBy = model.UpdatedBy + bean.UpdatedOn = model.UpdatedOn + // audit the existing description to cluster audit history + clusterAudit := &ClusterNoteHistoryBean{ + NoteId: bean.Id, + Description: bean.Description, + CreatedOn: model.CreatedOn, + CreatedBy: model.CreatedBy, + } + _, _ = impl.clusterNoteHistoryService.Save(clusterAudit, userId) + return bean, err +} + +func (impl *ClusterNoteServiceImpl) Update(bean *ClusterNoteBean, userId int32) (*ClusterNoteBean, error) { + model, err := impl.clusterNoteRepository.FindByClusterId(bean.ClusterId) + if err != nil { + impl.logger.Error(err) + return nil, err + } + if model.Id == 0 { + impl.logger.Errorw("error on fetching cluster note, not found", "id", bean.Id) + return nil, fmt.Errorf("cluster note not found") + } + // update the cluster description with new data + model.Description = bean.Description + model.UpdatedBy = userId + model.UpdatedOn = time.Now() + + err = impl.clusterNoteRepository.Update(model) + if err != nil { + err = &util.ApiError{ + Code: constants.ClusterUpdateDBFailed, + InternalMessage: "cluster note update failed in db", + UserMessage: fmt.Sprintf("requested by %d", userId), + } + return nil, err + } + bean.Id = model.Id + bean.UpdatedBy = model.UpdatedBy + bean.UpdatedOn = model.UpdatedOn + // audit the existing description to cluster audit history + clusterAudit := &ClusterNoteHistoryBean{ + NoteId: bean.Id, + Description: bean.Description, + CreatedOn: model.CreatedOn, + CreatedBy: model.CreatedBy, + } + _, _ = impl.clusterNoteHistoryService.Save(clusterAudit, userId) + return bean, err +} diff --git a/pkg/cluster/ClusterNoteService_test.go b/pkg/cluster/ClusterNoteService_test.go new file mode 100644 index 0000000000..1a86979795 --- /dev/null +++ b/pkg/cluster/ClusterNoteService_test.go @@ -0,0 +1,217 @@ +package cluster + +import ( + "log" + "testing" + "time" + + "github.com/caarlos0/env" + "github.com/devtron-labs/devtron/internal/util" + "github.com/devtron-labs/devtron/pkg/cluster/repository" + "github.com/go-pg/pg" + "github.com/stretchr/testify/assert" +) + +type Config struct { + Addr string `env:"TEST_PG_ADDR" envDefault:"127.0.0.1"` + Port string `env:"TEST_PG_PORT" envDefault:"5432"` + User string `env:"TEST_PG_USER" envDefault:"postgres"` + Password string `env:"TEST_PG_PASSWORD" envDefault:"postgrespw" secretData:"-"` + Database string `env:"TEST_PG_DATABASE" envDefault:"orchestrator"` + LogQuery bool `env:"TEST_PG_LOG_QUERY" envDefault:"true"` +} + +var clusterNoteService *ClusterNoteServiceImpl + +func TestClusterNoteService_Save(t *testing.T) { + if clusterNoteService == nil { + InitClusterNoteService() + } + initialiseDb(t) + testCases := []struct { + name string + input *ClusterNoteBean + expectedErr bool + }{ + { + name: "TEST : successfully save the note", + input: &ClusterNoteBean{ + Id: 0, + ClusterId: 10000, + Description: "Test Note", + UpdatedBy: 1, + UpdatedOn: time.Now(), + }, + expectedErr: false, + }, + { + name: "TEST : error while saving the existing note", + input: &ClusterNoteBean{ + Id: 0, + ClusterId: 10000, + Description: "Test Note", + UpdatedBy: 1, + UpdatedOn: time.Now(), + }, + expectedErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + res, err := clusterNoteService.Save(tc.input, 1) + if tc.expectedErr { + assert.NotNil(tt, err) + } else { + assert.Nil(tt, err) + assert.NotEqual(tt, res.Id, 0) + } + }) + } + //clean data in db + cleanDb(t) +} + +func TestClusterNoteServiceImpl_Update(t *testing.T) { + if clusterNoteService == nil { + InitClusterNoteService() + } + initialiseDb(t) + // insert a cluster note in the database which will be updated later + note := &ClusterNoteBean{ + ClusterId: 10001, + Description: "test note", + UpdatedBy: 1, + } + _, err := clusterNoteService.Save(note, 1) + if err != nil { + t.Fatalf("Error inserting record in database: %s", err.Error()) + } + + // define input for update function + testCases := []struct { + name string + input *ClusterNoteBean + expectedErr bool + }{ + { + name: "TEST : error while updating the existing note", + input: &ClusterNoteBean{ + Id: 1, + ClusterId: 100, + }, + expectedErr: true, + }, + { + name: "TEST : successfully update the note", + input: &ClusterNoteBean{ + Description: "Updated Text", + ClusterId: 10001, + }, + expectedErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + res, err := clusterNoteService.Update(tc.input, 1) + if tc.expectedErr { + assert.NotNil(tt, err) + } else { + assert.Nil(tt, err) + assert.NotEqual(tt, res.Id, 0) + } + }) + } + + //clean data in db + cleanDb(t) +} + +var db *pg.DB + +func getDbConn() (*pg.DB, error) { + if db != nil { + return db, nil + } + cfg := Config{} + err := env.Parse(&cfg) + if err != nil { + return nil, err + } + options := pg.Options{ + Addr: cfg.Addr + ":" + cfg.Port, + User: cfg.User, + Password: cfg.Password, + Database: cfg.Database, + } + db = pg.Connect(&options) + return db, nil +} + +func cleanDb(tt *testing.T) { + DB, _ := getDbConn() + query := "DELETE FROM cluster_note_history WHERE note_id IN (SELECT id FROM cluster_note);\n" + + "DELETE FROM cluster_note WHERE cluster_id IN (SELECT id FROM cluster);\n" + + "DELETE FROM cluster WHERE id=10000 OR id=10001;\n" + _, err := DB.Exec(query) + assert.Nil(tt, err) + if err != nil { + return + } +} + +func createClusterData(DB *pg.DB, bean *ClusterBean) error { + model := &repository.Cluster{ + Id: bean.Id, + ClusterName: bean.ClusterName, + ServerUrl: bean.ServerUrl, + } + model.CreatedBy = 1 + model.UpdatedBy = 1 + model.CreatedOn = time.Now() + model.UpdatedOn = time.Now() + return DB.Insert(model) +} + +func initialiseDb(tt *testing.T) { + DB, _ := getDbConn() + clusters := []ClusterBean{ + { + Id: 10000, + ClusterName: "test-cluster-1", + ServerUrl: "https://test1.cluster", + }, + { + Id: 10001, + ClusterName: "test-cluster-2", + ServerUrl: "https://test2.cluster", + }, + } + for _, cluster := range clusters { + err := createClusterData(DB, &cluster) + assert.Nil(tt, err) + if err != nil { + return + } + } +} + +func InitClusterNoteService() { + if clusterNoteService != nil { + return + } + logger, err := util.NewSugardLogger() + if err != nil { + log.Fatalf("error in logger initialization %s,%s", "err", err) + } + conn, err := getDbConn() + if err != nil { + log.Fatalf("error in db connection initialization %s, %s", "err", err) + } + + clusterNoteHistoryRepository := repository.NewClusterNoteHistoryRepositoryImpl(conn, logger) + clusterNoteRepository := repository.NewClusterNoteRepositoryImpl(conn, logger) + clusterNoteHistoryService := NewClusterNoteHistoryServiceImpl(clusterNoteHistoryRepository, logger) + clusterNoteService = NewClusterNoteServiceImpl(clusterNoteRepository, clusterNoteHistoryService, logger) +} diff --git a/pkg/cluster/repository/ClusterDescriptionRepository.go b/pkg/cluster/repository/ClusterDescriptionRepository.go new file mode 100644 index 0000000000..0267bd57dc --- /dev/null +++ b/pkg/cluster/repository/ClusterDescriptionRepository.go @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 Devtron Labs + * + * 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 repository + +import ( + "fmt" + "github.com/devtron-labs/devtron/pkg/sql" + "github.com/go-pg/pg" + "go.uber.org/zap" + "time" +) + +type ClusterDescription struct { + ClusterId int `sql:"cluster_id"` + ClusterName string `sql:"cluster_name"` + ClusterCreatedOn time.Time `sql:"cluster_created_on"` + ClusterCreatedBy int32 `sql:"cluster_created_by"` + NoteId int `sql:"note_id,pk"` + Description string `sql:"description"` + sql.AuditLog +} + +type ClusterDescriptionRepository interface { + FindByClusterIdWithClusterDetails(id int) (*ClusterDescription, error) +} + +func NewClusterDescriptionRepositoryImpl(dbConnection *pg.DB, logger *zap.SugaredLogger) *ClusterDescriptionRepositoryImpl { + return &ClusterDescriptionRepositoryImpl{ + dbConnection: dbConnection, + logger: logger, + } +} + +type ClusterDescriptionRepositoryImpl struct { + dbConnection *pg.DB + logger *zap.SugaredLogger +} + +func (impl ClusterDescriptionRepositoryImpl) FindByClusterIdWithClusterDetails(id int) (*ClusterDescription, error) { + clusterDescription := &ClusterDescription{} + query := fmt.Sprintf("select cl.id as cluster_id, cl.cluster_name as cluster_name, cl.created_on as cluster_created_on, cl.created_by as cluster_created_by, cln.id as note_id, cln.description, cln.created_by, cln.created_on, cln.updated_by, cln.updated_on from cluster cl left join cluster_note cln on cl.id=cln.cluster_id where cl.id=%d and cl.active=true limit 1 offset 0;", id) + _, err := impl.dbConnection.Query(clusterDescription, query) + return clusterDescription, err +} diff --git a/pkg/cluster/repository/ClusterNoteHistoryRepository.go b/pkg/cluster/repository/ClusterNoteHistoryRepository.go new file mode 100644 index 0000000000..bfff5c8015 --- /dev/null +++ b/pkg/cluster/repository/ClusterNoteHistoryRepository.go @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020 Devtron Labs + * + * 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 repository + +import ( + "github.com/devtron-labs/devtron/pkg/sql" + "github.com/go-pg/pg" + "go.uber.org/zap" +) + +type ClusterNoteHistory struct { + tableName struct{} `sql:"cluster_note_history" pg:",discard_unknown_columns"` + Id int `sql:"id,pk"` + NoteId int `sql:"note_id"` + Description string `sql:"description"` + sql.AuditLog +} + +type ClusterNoteHistoryRepository interface { + SaveHistory(model *ClusterNoteHistory) error + FindHistoryByNoteId(id []int) ([]ClusterNoteHistory, error) +} + +func NewClusterNoteHistoryRepositoryImpl(dbConnection *pg.DB, logger *zap.SugaredLogger) *ClusterNoteHistoryRepositoryImpl { + return &ClusterNoteHistoryRepositoryImpl{ + dbConnection: dbConnection, + logger: logger, + } +} + +type ClusterNoteHistoryRepositoryImpl struct { + dbConnection *pg.DB + logger *zap.SugaredLogger +} + +func (impl ClusterNoteHistoryRepositoryImpl) SaveHistory(model *ClusterNoteHistory) error { + return impl.dbConnection.Insert(model) +} + +func (impl ClusterNoteHistoryRepositoryImpl) FindHistoryByNoteId(id []int) ([]ClusterNoteHistory, error) { + var clusterNoteHistories []ClusterNoteHistory + err := impl.dbConnection. + Model(&clusterNoteHistories). + Where("note_id =?", id). + Select() + return clusterNoteHistories, err +} diff --git a/pkg/cluster/repository/ClusterNoteRepository.go b/pkg/cluster/repository/ClusterNoteRepository.go new file mode 100644 index 0000000000..e9c8726204 --- /dev/null +++ b/pkg/cluster/repository/ClusterNoteRepository.go @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020 Devtron Labs + * + * 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 repository + +import ( + "github.com/devtron-labs/devtron/pkg/sql" + "github.com/go-pg/pg" + "go.uber.org/zap" +) + +type ClusterNote struct { + tableName struct{} `sql:"cluster_note" pg:",discard_unknown_columns"` + Id int `sql:"id,pk"` + ClusterId int `sql:"cluster_id"` + Description string `sql:"description"` + sql.AuditLog +} + +type ClusterNoteRepository interface { + Save(model *ClusterNote) error + FindByClusterId(id int) (*ClusterNote, error) + Update(model *ClusterNote) error +} + +func NewClusterNoteRepositoryImpl(dbConnection *pg.DB, logger *zap.SugaredLogger) *ClusterNoteRepositoryImpl { + return &ClusterNoteRepositoryImpl{ + dbConnection: dbConnection, + logger: logger, + } +} + +type ClusterNoteRepositoryImpl struct { + dbConnection *pg.DB + logger *zap.SugaredLogger +} + +func (impl ClusterNoteRepositoryImpl) Save(model *ClusterNote) error { + return impl.dbConnection.Insert(model) +} + +func (impl ClusterNoteRepositoryImpl) FindByClusterId(id int) (*ClusterNote, error) { + clusterNote := &ClusterNote{} + err := impl.dbConnection. + Model(clusterNote). + Where("cluster_id =?", id). + Limit(1). + Select() + return clusterNote, err +} + +func (impl ClusterNoteRepositoryImpl) Update(model *ClusterNote) error { + return impl.dbConnection.Update(model) +} diff --git a/scripts/sql/134_alter_cluster_details.down.sql b/scripts/sql/134_alter_cluster_details.down.sql new file mode 100644 index 0000000000..d6c90bb81e --- /dev/null +++ b/scripts/sql/134_alter_cluster_details.down.sql @@ -0,0 +1,7 @@ +---- DROP table +DROP TABLE IF EXISTS "cluster_note_history"; +DROP TABLE IF EXISTS "cluster_note"; + +---- DROP sequence +DROP SEQUENCE IF EXISTS public.id_seq_cluster_note; +DROP SEQUENCE IF EXISTS public.id_seq_cluster_note_history; \ No newline at end of file diff --git a/scripts/sql/134_alter_cluster_details.up.sql b/scripts/sql/134_alter_cluster_details.up.sql new file mode 100644 index 0000000000..b68ec14e20 --- /dev/null +++ b/scripts/sql/134_alter_cluster_details.up.sql @@ -0,0 +1,37 @@ +-- Sequence and defined type for cluster_note +CREATE SEQUENCE IF NOT EXISTS id_seq_cluster_note; + +-- Sequence and defined type for cluster_note_history +CREATE SEQUENCE IF NOT EXISTS id_seq_cluster_note_history; + +-- cluster_note Table Definition +CREATE TABLE IF NOT EXISTS public.cluster_note +( + "id" int4 NOT NULL DEFAULT nextval('id_seq_cluster_note'::regclass), + "cluster_id" int4 NOT NULL, + "description" TEXT NOT NULL, + "created_on" timestamptz NOT NULL, + "created_by" int4 NOT NULL, + "updated_on" timestamptz, + "updated_by" int4, + PRIMARY KEY ("id"), + CONSTRAINT cluster_note_cluster_id_fkey + FOREIGN KEY(cluster_id) + REFERENCES public.cluster(id) +); + +-- cluster_note_history Table Definition +CREATE TABLE IF NOT EXISTS public.cluster_note_history +( + "id" int4 NOT NULL DEFAULT nextval('id_seq_cluster_note_history'::regclass), + "note_id" int4 NOT NULL, + "description" TEXT NOT NULL, + "created_on" timestamptz NOT NULL, + "created_by" int4 NOT NULL, + "updated_on" timestamptz, + "updated_by" int4, + PRIMARY KEY ("id"), + CONSTRAINT cluster_note_history_cluster_note_id_fkey + FOREIGN KEY(note_id) + REFERENCES public.cluster_note(id) +); \ No newline at end of file diff --git a/wire_gen.go b/wire_gen.go index 838d322a25..b2cd5d9f85 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -468,7 +468,13 @@ func InitializeApp() (*App, error) { deleteServiceExtendedImpl := delete2.NewDeleteServiceExtendedImpl(sugaredLogger, teamServiceImpl, clusterServiceImplExtended, environmentServiceImpl, appRepositoryImpl, environmentRepositoryImpl, pipelineRepositoryImpl, chartRepositoryServiceImpl, installedAppRepositoryImpl) environmentRestHandlerImpl := cluster3.NewEnvironmentRestHandlerImpl(environmentServiceImpl, sugaredLogger, userServiceImpl, validate, enforcerImpl, deleteServiceExtendedImpl) environmentRouterImpl := cluster3.NewEnvironmentRouterImpl(environmentRestHandlerImpl) - clusterRestHandlerImpl := cluster3.NewClusterRestHandlerImpl(clusterServiceImplExtended, sugaredLogger, userServiceImpl, validate, enforcerImpl, deleteServiceExtendedImpl, argoUserServiceImpl) + clusterNoteRepositoryImpl := repository2.NewClusterNoteRepositoryImpl(db, sugaredLogger) + clusterNoteHistoryRepositoryImpl := repository2.NewClusterNoteHistoryRepositoryImpl(db, sugaredLogger) + clusterNoteHistoryServiceImpl := cluster2.NewClusterNoteHistoryServiceImpl(clusterNoteHistoryRepositoryImpl, sugaredLogger) + clusterNoteServiceImpl := cluster2.NewClusterNoteServiceImpl(clusterNoteRepositoryImpl, clusterNoteHistoryServiceImpl, sugaredLogger) + clusterDescriptionRepositoryImpl := repository2.NewClusterDescriptionRepositoryImpl(db, sugaredLogger) + clusterDescriptionServiceImpl := cluster2.NewClusterDescriptionServiceImpl(clusterDescriptionRepositoryImpl, userRepositoryImpl, sugaredLogger) + clusterRestHandlerImpl := cluster3.NewClusterRestHandlerImpl(clusterServiceImplExtended, clusterNoteServiceImpl, clusterDescriptionServiceImpl, sugaredLogger, userServiceImpl, validate, enforcerImpl, deleteServiceExtendedImpl, argoUserServiceImpl, environmentServiceImpl) clusterRouterImpl := cluster3.NewClusterRouterImpl(clusterRestHandlerImpl) gitWebhookRepositoryImpl := repository.NewGitWebhookRepositoryImpl(db) gitWebhookServiceImpl := git.NewGitWebhookServiceImpl(sugaredLogger, ciHandlerImpl, gitWebhookRepositoryImpl)