From 7313de2b1df533bc4ba39098d3e8b5403d4ad146 Mon Sep 17 00:00:00 2001 From: Zav Shotan <3694482+LamaAni@users.noreply.github.com> Date: Mon, 9 May 2022 05:26:09 -0400 Subject: [PATCH] Add support for superseding runs (#831) closes #11 Added support: 1. Environment variable `WOODPECKER_DELETE_MULTIPLE_RUNS_ON_EVENTS` (Default pull_request, push) 2. Builds will be marked as killed when they "override" another build --- cmd/server/flags.go | 6 + cmd/server/server.go | 9 + docs/docs/20-usage/71-project-settings.md | 3 + .../30-administration/10-server-config.md | 5 + server/api/build.go | 161 ++++++++++++++---- server/api/repo.go | 4 + server/config.go | 13 +- server/model/repo.go | 56 +++--- server/store/datastore/build.go | 12 ++ server/store/store.go | 2 + .../components/repo/settings/GeneralTab.vue | 31 +++- web/src/lib/api/types/repo.ts | 36 ++-- 12 files changed, 254 insertions(+), 84 deletions(-) diff --git a/cmd/server/flags.go b/cmd/server/flags.go index 8afc3537a..e8dd26390 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -97,6 +97,12 @@ var flags = []cli.Flag{ Name: "authenticate-public-repos", Usage: "Always use authentication to clone repositories even if they are public. Needed if the SCM requires to always authenticate as used by many companies.", }, + &cli.StringSliceFlag{ + EnvVars: []string{"WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS"}, + Name: "default-cancel-previous-pipeline-events", + Usage: "List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.", + Value: cli.NewStringSlice("push", "pull_request"), + }, &cli.StringFlag{ EnvVars: []string{"WOODPECKER_DEFAULT_CLONE_IMAGE"}, Name: "default-clone-image", diff --git a/cmd/server/server.go b/cmd/server/server.go index 8fc0a4189..466f9ff0c 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -41,6 +41,7 @@ import ( "github.com/woodpecker-ci/woodpecker/server" woodpeckerGrpcServer "github.com/woodpecker-ci/woodpecker/server/grpc" "github.com/woodpecker-ci/woodpecker/server/logging" + "github.com/woodpecker-ci/woodpecker/server/model" "github.com/woodpecker-ci/woodpecker/server/plugins/configuration" "github.com/woodpecker-ci/woodpecker/server/plugins/sender" "github.com/woodpecker-ci/woodpecker/server/pubsub" @@ -287,6 +288,14 @@ func setupEvilGlobals(c *cli.Context, v store.Store, r remote.Remote) { // Cloning server.Config.Pipeline.DefaultCloneImage = c.String("default-clone-image") + // Execution + _events := c.StringSlice("default-cancel-previous-pipeline-events") + events := make([]model.WebhookEvent, len(_events)) + for _, v := range _events { + events = append(events, model.WebhookEvent(v)) + } + server.Config.Pipeline.DefaultCancelPreviousPipelineEvents = events + // limits server.Config.Pipeline.Limits.MemSwapLimit = c.Int64("limit-mem-swap") server.Config.Pipeline.Limits.MemLimit = c.Int64("limit-mem") diff --git a/docs/docs/20-usage/71-project-settings.md b/docs/docs/20-usage/71-project-settings.md index 707431633..6e13e616d 100644 --- a/docs/docs/20-usage/71-project-settings.md +++ b/docs/docs/20-usage/71-project-settings.md @@ -40,3 +40,6 @@ You can change the visibility of your project by this setting. If a user has acc After this timeout a pipeline has to finish or will be treated as timed out. +## Cancel previous pipelines + +By enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one. diff --git a/docs/docs/30-administration/10-server-config.md b/docs/docs/30-administration/10-server-config.md index e114990ae..1044c5ec7 100644 --- a/docs/docs/30-administration/10-server-config.md +++ b/docs/docs/30-administration/10-server-config.md @@ -197,6 +197,11 @@ Link to documentation in the UI. Always use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies. +### `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS` +> Default: `pull_request, push` + +List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created. + ### `WOODPECKER_DEFAULT_CLONE_IMAGE` > Default: `woodpeckerci/plugin-git:latest` diff --git a/server/api/build.go b/server/api/build.go index afa38f4f0..ae4fbef60 100644 --- a/server/api/build.go +++ b/server/api/build.go @@ -216,45 +216,60 @@ func DeleteBuild(c *gin.Context) { return } - procs, err := _store.ProcList(build) - if err != nil { - _ = c.AbortWithError(http.StatusNotFound, err) - return - } - if build.Status != model.StatusRunning && build.Status != model.StatusPending { c.String(http.StatusBadRequest, "Cannot cancel a non-running or non-pending build") return } + code, err := cancelBuild(c, _store, repo, build) + if err != nil { + _ = c.AbortWithError(code, err) + return + } + + c.String(code, "") +} + +// Cancel the build and returns the status. +func cancelBuild( + ctx context.Context, + _store store.Store, + repo *model.Repo, + build *model.Build, +) (int, error) { + procs, err := _store.ProcList(build) + if err != nil { + return http.StatusNotFound, err + } + // First cancel/evict procs in the queue in one go var ( - procToCancel []string - procToEvict []string + procsToCancel []string + procsToEvict []string ) for _, proc := range procs { if proc.PPID != 0 { continue } if proc.State == model.StatusRunning { - procToCancel = append(procToCancel, fmt.Sprint(proc.ID)) + procsToCancel = append(procsToCancel, fmt.Sprint(proc.ID)) } if proc.State == model.StatusPending { - procToEvict = append(procToEvict, fmt.Sprint(proc.ID)) + procsToEvict = append(procsToEvict, fmt.Sprint(proc.ID)) } } - if len(procToEvict) != 0 { - if err := server.Config.Services.Queue.EvictAtOnce(c, procToEvict); err != nil { - log.Error().Err(err).Msgf("queue: evict_at_once: %v", procToEvict) + if len(procsToEvict) != 0 { + if err := server.Config.Services.Queue.EvictAtOnce(ctx, procsToEvict); err != nil { + log.Error().Err(err).Msgf("queue: evict_at_once: %v", procsToEvict) } - if err := server.Config.Services.Queue.ErrorAtOnce(c, procToEvict, queue.ErrCancel); err != nil { - log.Error().Err(err).Msgf("queue: evict_at_once: %v", procToEvict) + if err := server.Config.Services.Queue.ErrorAtOnce(ctx, procsToEvict, queue.ErrCancel); err != nil { + log.Error().Err(err).Msgf("queue: evict_at_once: %v", procsToEvict) } } - if len(procToCancel) != 0 { - if err := server.Config.Services.Queue.ErrorAtOnce(c, procToCancel, queue.ErrCancel); err != nil { - log.Error().Err(err).Msgf("queue: evict_at_once: %v", procToCancel) + if len(procsToCancel) != 0 { + if err := server.Config.Services.Queue.ErrorAtOnce(ctx, procsToCancel, queue.ErrCancel); err != nil { + log.Error().Err(err).Msgf("queue: evict_at_once: %v", procsToCancel) } } @@ -277,8 +292,7 @@ func DeleteBuild(c *gin.Context) { killedBuild, err := shared.UpdateToStatusKilled(_store, *build) if err != nil { log.Error().Err(err).Msgf("UpdateToStatusKilled: %v", build) - _ = c.AbortWithError(http.StatusInternalServerError, err) - return + return http.StatusInternalServerError, err } // For pending builds, we stream the UI the latest state. @@ -286,19 +300,17 @@ func DeleteBuild(c *gin.Context) { if build.Status == model.StatusPending { procs, err = _store.ProcList(killedBuild) if err != nil { - _ = c.AbortWithError(404, err) - return + return http.StatusNotFound, err } if killedBuild.Procs, err = model.Tree(procs); err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return + return http.StatusInternalServerError, err } - if err := publishToTopic(c, killedBuild, repo); err != nil { + if err := publishToTopic(ctx, killedBuild, repo); err != nil { log.Error().Err(err).Msg("publishToTopic") } } - c.String(204, "") + return http.StatusNoContent, nil } func PostApproval(c *gin.Context) { @@ -651,26 +663,109 @@ func createBuildItems(ctx context.Context, store store.Store, build *model.Build return build, buildItems, nil } -func startBuild(ctx context.Context, store store.Store, build *model.Build, user *model.User, repo *model.Repo, buildItems []*shared.BuildItem) (*model.Build, error) { - if err := store.ProcCreate(build.Procs); err != nil { - log.Error().Err(err).Str("repo", repo.FullName).Msgf("error persisting procs for %s#%d", repo.FullName, build.Number) +func cancelPreviousPipelines( + ctx context.Context, + _store store.Store, + build *model.Build, + user *model.User, + repo *model.Repo, +) error { + // check this event should cancel previous pipelines + eventIncluded := false + for _, ev := range repo.CancelPreviousPipelineEvents { + if ev == build.Event { + eventIncluded = true + break + } + } + if !eventIncluded { + return nil + } + + // get all active activeBuilds + activeBuilds, err := _store.GetActiveBuildList(repo, -1) + if err != nil { + return err + } + + buildNeedsCancel := func(active *model.Build) (bool, error) { + // always filter on same event + if active.Event != build.Event { + return false, nil + } + + // find events for the same context + switch build.Event { + case model.EventPush: + return build.Branch == active.Branch, nil + default: + return build.Refspec == active.Refspec, nil + } + } + + for _, active := range activeBuilds { + if active.ID == build.ID { + // same build. e.g. self + continue + } + + cancel, err := buildNeedsCancel(active) + if err != nil { + log.Error(). + Err(err). + Str("Ref", active.Ref). + Msg("Error while trying to cancel build, skipping") + continue + } + if !cancel { + continue + } + _, err = cancelBuild(ctx, _store, repo, active) + if err != nil { + log.Error(). + Err(err). + Str("Ref", active.Ref). + Int64("ID", active.ID). + Msg("Failed to cancel build") + } + } + + return nil +} + +func startBuild( + ctx context.Context, + store store.Store, + activeBuild *model.Build, + user *model.User, + repo *model.Repo, + buildItems []*shared.BuildItem, +) (*model.Build, error) { + // call to cancel previous builds if needed + if err := cancelPreviousPipelines(ctx, store, activeBuild, user, repo); err != nil { + // should be not breaking + log.Error().Err(err).Msg("Failed to cancel previous builds") + } + + if err := store.ProcCreate(activeBuild.Procs); err != nil { + log.Error().Err(err).Str("repo", repo.FullName).Msgf("error persisting procs for %s#%d", repo.FullName, activeBuild.Number) return nil, err } - if err := publishToTopic(ctx, build, repo); err != nil { + if err := publishToTopic(ctx, activeBuild, repo); err != nil { log.Error().Err(err).Msg("publishToTopic") } - if err := queueBuild(build, repo, buildItems); err != nil { + if err := queueBuild(activeBuild, repo, buildItems); err != nil { log.Error().Err(err).Msg("queueBuild") return nil, err } - if err := updateBuildStatus(ctx, build, repo, user); err != nil { + if err := updateBuildStatus(ctx, activeBuild, repo, user); err != nil { log.Error().Err(err).Msg("updateBuildStatus") } - return build, nil + return activeBuild, nil } func updateBuildStatus(ctx context.Context, build *model.Build, repo *model.Repo, user *model.User) error { diff --git a/server/api/repo.go b/server/api/repo.go index e0e3b0c84..5b0f96b1c 100644 --- a/server/api/repo.go +++ b/server/api/repo.go @@ -51,6 +51,7 @@ func PostRepo(c *gin.Context) { repo.IsActive = true repo.UserID = user.ID repo.AllowPull = true + repo.CancelPreviousPipelineEvents = server.Config.Pipeline.DefaultCancelPreviousPipelineEvents if repo.Visibility == "" { repo.Visibility = model.VisibilityPublic @@ -140,6 +141,9 @@ func PatchRepo(c *gin.Context) { if in.Config != nil { repo.Config = *in.Config } + if in.CancelPreviousPipelineEvents != nil { + repo.CancelPreviousPipelineEvents = *in.CancelPreviousPipelineEvents + } if in.Visibility != nil { switch *in.Visibility { case string(model.VisibilityInternal), string(model.VisibilityPrivate), string(model.VisibilityPublic): diff --git a/server/config.go b/server/config.go index 45d685794..314994042 100644 --- a/server/config.go +++ b/server/config.go @@ -68,12 +68,13 @@ var Config = struct { AuthToken string } Pipeline struct { - AuthenticatePublicRepos bool - DefaultCloneImage string - Limits model.ResourceLimit - Volumes []string - Networks []string - Privileged []string + AuthenticatePublicRepos bool + DefaultCancelPreviousPipelineEvents []model.WebhookEvent + DefaultCloneImage string + Limits model.ResourceLimit + Volumes []string + Networks []string + Privileged []string } FlatPermissions bool // TODO(485) temporary workaround to not hit api rate limits }{} diff --git a/server/model/repo.go b/server/model/repo.go index 0d15ccb9b..f72b98b1c 100644 --- a/server/model/repo.go +++ b/server/model/repo.go @@ -24,27 +24,28 @@ import ( // // swagger:model repo type Repo struct { - ID int64 `json:"id,omitempty" xorm:"pk autoincr 'repo_id'"` - UserID int64 `json:"-" xorm:"repo_user_id"` - Owner string `json:"owner" xorm:"UNIQUE(name) 'repo_owner'"` - Name string `json:"name" xorm:"UNIQUE(name) 'repo_name'"` - FullName string `json:"full_name" xorm:"UNIQUE 'repo_full_name'"` - Avatar string `json:"avatar_url,omitempty" xorm:"varchar(500) 'repo_avatar'"` - Link string `json:"link_url,omitempty" xorm:"varchar(1000) 'repo_link'"` - Clone string `json:"clone_url,omitempty" xorm:"varchar(1000) 'repo_clone'"` - Branch string `json:"default_branch,omitempty" xorm:"varchar(500) 'repo_branch'"` - SCMKind SCMKind `json:"scm,omitempty" xorm:"varchar(50) 'repo_scm'"` - Timeout int64 `json:"timeout,omitempty" xorm:"repo_timeout"` - Visibility RepoVisibly `json:"visibility" xorm:"varchar(10) 'repo_visibility'"` - IsSCMPrivate bool `json:"private" xorm:"repo_private"` - IsTrusted bool `json:"trusted" xorm:"repo_trusted"` - IsStarred bool `json:"starred,omitempty" xorm:"-"` - IsGated bool `json:"gated" xorm:"repo_gated"` - IsActive bool `json:"active" xorm:"repo_active"` - AllowPull bool `json:"allow_pr" xorm:"repo_allow_pr"` - Config string `json:"config_file" xorm:"varchar(500) 'repo_config_path'"` - Hash string `json:"-" xorm:"varchar(500) 'repo_hash'"` - Perm *Perm `json:"-" xorm:"-"` + ID int64 `json:"id,omitempty" xorm:"pk autoincr 'repo_id'"` + UserID int64 `json:"-" xorm:"repo_user_id"` + Owner string `json:"owner" xorm:"UNIQUE(name) 'repo_owner'"` + Name string `json:"name" xorm:"UNIQUE(name) 'repo_name'"` + FullName string `json:"full_name" xorm:"UNIQUE 'repo_full_name'"` + Avatar string `json:"avatar_url,omitempty" xorm:"varchar(500) 'repo_avatar'"` + Link string `json:"link_url,omitempty" xorm:"varchar(1000) 'repo_link'"` + Clone string `json:"clone_url,omitempty" xorm:"varchar(1000) 'repo_clone'"` + Branch string `json:"default_branch,omitempty" xorm:"varchar(500) 'repo_branch'"` + SCMKind SCMKind `json:"scm,omitempty" xorm:"varchar(50) 'repo_scm'"` + Timeout int64 `json:"timeout,omitempty" xorm:"repo_timeout"` + Visibility RepoVisibly `json:"visibility" xorm:"varchar(10) 'repo_visibility'"` + IsSCMPrivate bool `json:"private" xorm:"repo_private"` + IsTrusted bool `json:"trusted" xorm:"repo_trusted"` + IsStarred bool `json:"starred,omitempty" xorm:"-"` + IsGated bool `json:"gated" xorm:"repo_gated"` + IsActive bool `json:"active" xorm:"repo_active"` + AllowPull bool `json:"allow_pr" xorm:"repo_allow_pr"` + Config string `json:"config_file" xorm:"varchar(500) 'repo_config_path'"` + Hash string `json:"-" xorm:"varchar(500) 'repo_hash'"` + Perm *Perm `json:"-" xorm:"-"` + CancelPreviousPipelineEvents []WebhookEvent `json:"cancel_previous_pipeline_events" xorm:"json 'cancel_previous_pipeline_events'"` } // TableName return database table name for xorm @@ -90,10 +91,11 @@ func (r *Repo) Update(from *Repo) { // RepoPatch represents a repository patch object. type RepoPatch struct { - Config *string `json:"config_file,omitempty"` - IsTrusted *bool `json:"trusted,omitempty"` - IsGated *bool `json:"gated,omitempty"` - Timeout *int64 `json:"timeout,omitempty"` - Visibility *string `json:"visibility,omitempty"` - AllowPull *bool `json:"allow_pr,omitempty"` + Config *string `json:"config_file,omitempty"` + IsTrusted *bool `json:"trusted,omitempty"` + IsGated *bool `json:"gated,omitempty"` + Timeout *int64 `json:"timeout,omitempty"` + Visibility *string `json:"visibility,omitempty"` + AllowPull *bool `json:"allow_pr,omitempty"` + CancelPreviousPipelineEvents *[]WebhookEvent `json:"cancel_previous_pipeline_events"` } diff --git a/server/store/datastore/build.go b/server/store/datastore/build.go index 1275df911..accb2ffd3 100644 --- a/server/store/datastore/build.go +++ b/server/store/datastore/build.go @@ -80,6 +80,18 @@ func (s storage) GetBuildList(repo *model.Repo, page int) ([]*model.Build, error Find(&builds) } +func (s storage) GetActiveBuildList(repo *model.Repo, page int) ([]*model.Build, error) { + builds := make([]*model.Build, 0, perPage) + query := s.engine. + Where("build_repo_id = ?", repo.ID). + Where("build_status = ? or build_status = ?", "pending", "running"). + Desc("build_number") + if page > 0 { + query = query.Limit(perPage, perPage*(page-1)) + } + return builds, query.Find(&builds) +} + func (s storage) GetBuildCount() (int64, error) { return s.engine.Count(new(model.Build)) } diff --git a/server/store/store.go b/server/store/store.go index fa309a7aa..3565833ca 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -70,6 +70,8 @@ type Store interface { // GetBuildList gets a list of builds for the repository // TODO: paginate GetBuildList(*model.Repo, int) ([]*model.Build, error) + // GetBuildList gets a list of the active builds for the repository + GetActiveBuildList(repo *model.Repo, page int) ([]*model.Build, error) // GetBuildQueue gets a list of build in queue. GetBuildQueue() ([]*model.Feed, error) // GetBuildCount gets a count of all builds in the system. diff --git a/web/src/components/repo/settings/GeneralTab.vue b/web/src/components/repo/settings/GeneralTab.vue index 0830db09c..c3c26add7 100644 --- a/web/src/components/repo/settings/GeneralTab.vue +++ b/web/src/components/repo/settings/GeneralTab.vue @@ -50,6 +50,18 @@ + + + + +