backend/server/services/project.go (396 lines of code) (raw):
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You 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 services
import (
"fmt"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
"strings"
"time"
"github.com/apache/incubator-devlake/core/dal"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/models"
"github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
)
type ProjectService interface {
RenameProject(db dal.Transaction, oldProjectName, newProjectName string) errors.Error
}
var projectService ProjectService
// ProjectQuery used to query projects as the api project input
type ProjectQuery struct {
Pagination
Keyword *string `json:"keyword" form:"keyword"`
}
func (query *ProjectQuery) GetKeyword() string {
if query != nil && query.Keyword != nil {
return strings.ToLower(*query.Keyword)
}
return ""
}
// GetProjects returns a paginated list of Projects based on `query`
func GetProjects(query *ProjectQuery) ([]*models.ApiOutputProject, int64, errors.Error) {
// verify input
if err := VerifyStruct(query); err != nil {
return nil, 0, err
}
clauses := []dal.Clause{
dal.From(&models.Project{}),
}
if query.Keyword != nil {
clauses = append(clauses, dal.Where("LOWER(name) LIKE ?", "%"+query.GetKeyword()+"%"))
}
count, err := db.Count(clauses...)
if err != nil {
return nil, 0, errors.Default.Wrap(err, "error getting DB count of project")
}
clauses = append(clauses,
dal.Orderby("created_at DESC"),
dal.Offset(query.GetSkip()),
dal.Limit(query.GetPageSize()),
)
projects := make([]*models.Project, count)
err = db.All(&projects, clauses...)
if err != nil {
return nil, 0, errors.Default.Wrap(err, "error finding DB project")
}
apiOutProjects := make([]*models.ApiOutputProject, len(projects))
g := new(errgroup.Group)
for idx, project := range projects {
tmpProject := *project
tmpIdx := idx
g.Go(func() error {
apiOutputProject, err := makeProjectOutput(&tmpProject, true)
if err != nil {
logger.Error(err, "makeProjectOutput, name: %s", tmpProject.Name)
return errors.Default.Wrap(err, "error making project output")
}
apiOutProjects[tmpIdx] = apiOutputProject
return nil
})
}
if err := g.Wait(); err != nil {
return nil, 0, errors.Convert(err)
}
return apiOutProjects, count, nil
}
// CreateProject accepts a project instance and insert it to database
func CreateProject(projectInput *models.ApiInputProject) (*models.ApiOutputProject, errors.Error) {
// verify input
if err := VerifyStruct(projectInput); err != nil {
return nil, err
}
// create transaction to updte multiple tables
var err errors.Error
tx := db.Begin()
defer func() {
if r := recover(); r != nil || err != nil {
err = tx.Rollback()
if err != nil {
logger.Error(err, "PatchProject: failed to rollback")
}
}
}()
// create project first
project := &models.Project{}
project.BaseProject = projectInput.BaseProject
err = db.Create(project)
if err != nil {
if db.IsDuplicationError(err) {
return nil, errors.BadInput.New(fmt.Sprintf("A project with name [%s] already exists", project.Name))
}
return nil, errors.Default.Wrap(err, "error creating DB project")
}
// check if we need flush the Metrics
if len(projectInput.Metrics) > 0 {
err = refreshProjectMetrics(tx, projectInput)
if err != nil {
return nil, err
}
}
// create blueprint
blueprint := &models.Blueprint{
Name: project.Name + "-Blueprint",
ProjectName: project.Name,
Mode: "NORMAL",
Enable: true,
CronConfig: "0 0 * * *",
IsManual: false,
SyncPolicy: models.SyncPolicy{
TimeAfter: func() *time.Time {
t := time.Now().AddDate(0, -6, 0)
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
return &t
}(),
},
Connections: nil,
}
if projectInput.Blueprint != nil {
blueprint = projectInput.Blueprint
}
err = tx.Create(blueprint)
if err != nil {
return nil, errors.Default.Wrap(err, "error creating DB blueprint")
}
// all good, commit transaction
err = tx.Commit()
if err != nil {
return nil, err
}
// reload schedule
err = reloadBlueprint(blueprint)
if err != nil {
return nil, err
}
return makeProjectOutput(project, false)
}
// GetProject returns a Project
func GetProject(name string) (*models.ApiOutputProject, errors.Error) {
// verify input
if name == "" {
return nil, errors.BadInput.New("project name is missing")
}
// load project
project, err := getProjectByName(db, name)
if err != nil {
return nil, err
}
// convert to api output
return makeProjectOutput(project, false)
}
// PatchProject FIXME ...
func PatchProject(name string, body map[string]interface{}) (*models.ApiOutputProject, errors.Error) {
projectInput := &models.ApiInputProject{}
// load input
err := helper.DecodeMapStruct(body, projectInput, true)
if err != nil {
return nil, err
}
// wrap all operation inside a transaction
tx := db.Begin()
defer func() {
if r := recover(); r != nil || err != nil {
err = tx.Rollback()
if err != nil {
logger.Error(err, "PatchProject: failed to rollback")
}
}
}()
project, err := getProjectByName(tx, name, dal.Lock(true, false))
if err != nil {
return nil, err
}
// allowed to changed the name
if projectInput.Name == "" {
projectInput.Name = name
}
project.BaseProject = projectInput.BaseProject
// name changed, updates the related entities as well
if name != project.Name {
// ProjectMetric
err = tx.UpdateColumn(
&models.ProjectMetricSetting{},
"project_name", project.Name,
dal.Where("project_name = ?", name),
)
if err != nil {
return nil, err
}
// ProjectPrMetric
err = tx.UpdateColumn(
&crossdomain.ProjectPrMetric{},
"project_name", project.Name,
dal.Where("project_name = ?", name),
)
if err != nil {
return nil, err
}
// ProjectIncidentDeploymentRelationship
err = tx.UpdateColumn(
&crossdomain.ProjectIncidentDeploymentRelationship{},
"project_name", project.Name,
dal.Where("project_name = ?", name),
)
if err != nil {
return nil, err
}
// ProjectMapping
err = tx.UpdateColumn(
&crossdomain.ProjectMapping{},
"project_name", project.Name,
dal.Where("project_name = ?", name),
)
if err != nil {
return nil, err
}
// Blueprint
err = tx.UpdateColumn(
&models.Blueprint{},
"project_name", project.Name,
dal.Where("project_name = ?", name),
)
if err != nil {
return nil, err
}
if projectService != nil {
if err := projectService.RenameProject(tx, name, project.Name); err != nil {
return nil, err
}
}
// rename project
err = tx.UpdateColumn(
&models.Project{},
"name", project.Name,
dal.Where("name = ?", name),
)
}
// Blueprint
// err = tx.UpdateColumn(
// &models.Blueprint{},
// "enable", projectInput.Enable,
// dal.Where("project_name = ?", name),
// )
// if err != nil {
// return nil, err
// }
// refresh project metrics if needed
if len(projectInput.Metrics) > 0 {
err = refreshProjectMetrics(tx, projectInput)
if err != nil {
return nil, err
}
}
// update project itself
err = tx.Update(project)
if err != nil {
return nil, err
}
// commit the transaction
err = tx.Commit()
if err != nil {
return nil, err
}
// all good, render output
return makeProjectOutput(project, false)
}
func thereAreUnfinishedPipelinesUnderProject(projectName string) (bool, errors.Error) {
blueprint, err := GetBlueprintByProjectName(projectName)
if err != nil {
return false, err
}
return thereAreUnfinishedPipelinesUnderBlueprint(blueprint.ID)
}
func thereAreUnfinishedPipelinesUnderBlueprint(blueprintID uint64) (bool, errors.Error) {
// get the first page
dbPipelines, count, err := GetDbPipelines(&PipelineQuery{BlueprintId: blueprintID})
if err != nil {
return false, err
}
if count <= 0 {
return false, nil
}
for _, pipeline := range dbPipelines {
if !slices.Contains(models.FinishedTaskStatus, pipeline.Status) {
return true, nil
}
}
return false, nil
}
// DeleteProject FIXME ...
func DeleteProject(name string) errors.Error {
// verify input
if name == "" {
return errors.BadInput.New("project name is missing")
}
// verify exists
_, err := getProjectByName(db, name)
if err != nil {
return err
}
// make sure there is no running pipelines in current projects
pipelinesAreUnfinished, err := thereAreUnfinishedPipelinesUnderProject(name)
if err != nil {
return err
}
if pipelinesAreUnfinished {
return errors.Default.New("There are unfinished pipelines in the current project. It cannot be deleted at this time.")
}
err = deleteProjectBlueprint(name)
if err != nil {
return err
}
tx := db.Begin()
defer func() {
if r := recover(); r != nil || err != nil {
err = tx.Rollback()
if err != nil {
logger.Error(err, "DeleteProject: failed to rollback")
}
}
}()
err = tx.Delete(&models.Project{}, dal.Where("name = ?", name))
if err != nil {
return errors.Default.Wrap(err, "error deleting project")
}
err = tx.Delete(&crossdomain.ProjectMapping{}, dal.Where("project_name = ?", name))
if err != nil {
return errors.Default.Wrap(err, "error deleting project")
}
err = tx.Delete(&models.ProjectMetricSetting{}, dal.Where("project_name = ?", name))
if err != nil {
return errors.Default.Wrap(err, "error deleting project metric setting")
}
err = tx.Delete(&crossdomain.ProjectPrMetric{}, dal.Where("project_name = ?", name))
if err != nil {
return errors.Default.Wrap(err, "error deleting project PR metric")
}
err = tx.Delete(&crossdomain.ProjectIncidentDeploymentRelationship{}, dal.Where("project_name = ?", name))
if err != nil {
return errors.Default.Wrap(err, "error deleting project Issue metric")
}
return tx.Commit()
}
func deleteProjectBlueprint(projectName string) errors.Error {
bp, err := bpManager.GetDbBlueprintByProjectName(projectName)
if err != nil {
if !db.IsErrorNotFound(err) {
return errors.Default.Wrap(err, fmt.Sprintf("error finding blueprint associated with project %s", projectName))
}
} else {
err = bpManager.DeleteBlueprint(bp.ID)
if err != nil {
return errors.Default.Wrap(err, fmt.Sprintf("error deleting blueprint associated with project %s", projectName))
}
}
return nil
}
func getProjectByName(tx dal.Dal, name string, additionalClauses ...dal.Clause) (*models.Project, errors.Error) {
project := &models.Project{}
err := tx.First(project, append([]dal.Clause{dal.Where("name = ?", name)}, additionalClauses...)...)
if err != nil {
if tx.IsErrorNotFound(err) {
return nil, errors.NotFound.Wrap(err, fmt.Sprintf("could not find project [%s] in DB", name))
}
return nil, errors.Default.Wrap(err, "error getting project from DB")
}
return project, nil
}
func refreshProjectMetrics(tx dal.Transaction, projectInput *models.ApiInputProject) errors.Error {
err := tx.Delete(&models.ProjectMetricSetting{}, dal.Where("project_name = ?", projectInput.Name))
if err != nil {
return err
}
for _, baseMetric := range projectInput.Metrics {
err = tx.Create(&models.ProjectMetricSetting{
BaseProjectMetricSetting: models.BaseProjectMetricSetting{
ProjectName: projectInput.Name,
BaseMetric: *baseMetric,
},
})
if err != nil {
return err
}
}
return nil
}
func makeProjectOutput(project *models.Project, withLastPipeline bool) (*models.ApiOutputProject, errors.Error) {
projectOutput := &models.ApiOutputProject{}
projectOutput.Project = *project
// load project metrics
projectMetrics := make([]models.ProjectMetricSetting, 0)
err := db.All(&projectMetrics, dal.Where("project_name = ?", projectOutput.Name))
if err != nil {
return nil, errors.Default.Wrap(err, "failed to load project metrics")
}
// convert metric to api output
if len(projectMetrics) > 0 {
baseMetrics := make([]*models.BaseMetric, len(projectMetrics))
for i, projectMetric := range projectMetrics {
baseMetric := projectMetric.BaseMetric
baseMetrics[i] = &baseMetric
}
projectOutput.Metrics = baseMetrics
}
// load blueprint
projectOutput.Blueprint, err = GetBlueprintByProjectName(projectOutput.Name)
if err != nil {
return nil, errors.Default.Wrap(err, "Error to get blueprint by project")
}
if projectOutput.Blueprint != nil {
if err := SanitizeBlueprint(projectOutput.Blueprint); err != nil {
return nil, errors.Convert(err)
}
}
if withLastPipeline {
if projectOutput.Blueprint == nil {
logger.Warn(fmt.Errorf("blueprint is nil"), "want to get latest pipeline, but blueprint is nil")
} else {
pipelines, pipelinesCount, err := GetPipelines(&PipelineQuery{
BlueprintId: projectOutput.Blueprint.ID,
Pagination: Pagination{
PageSize: 1,
Page: 1,
},
}, true)
if err != nil {
logger.Error(err, "GetPipelines, blueprint id: %d", projectOutput.Blueprint.ID)
return nil, errors.Default.Wrap(err, "Error to get pipeline by blueprint id")
}
if pipelinesCount > 0 {
projectOutput.LastPipeline = pipelines[0]
}
}
}
return projectOutput, err
}