From 0b379f62b0af6b71681a1e0d97efdb256f295398 Mon Sep 17 00:00:00 2001 From: Nish Krishnan Date: Thu, 28 Jan 2021 11:12:43 -0800 Subject: [PATCH] [ORCA-554] Add support for limiting number of projects per PR. (#41) --- cmd/server.go | 9 ++ server/events/project_command_builder.go | 46 +++++- .../size_limited_project_command_builder.go | 48 ++++++ ...ze_limited_project_command_builder_test.go | 143 ++++++++++++++++++ server/server.go | 3 +- server/user_config.go | 1 + 6 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 server/events/size_limited_project_command_builder.go create mode 100644 server/events/size_limited_project_command_builder_test.go diff --git a/cmd/server.go b/cmd/server.go index 17fa61b93f..afa272f19b 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -24,6 +24,7 @@ import ( homedir "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server" + "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/yaml/valid" "github.com/runatlantis/atlantis/server/logging" @@ -76,6 +77,7 @@ const ( HidePrevPlanComments = "hide-prev-plan-comments" LogLevelFlag = "log-level" ParallelPoolSize = "parallel-pool-size" + MaxProjectsPerPR = "max-projects-per-pr" StatsNamespace = "stats-namespace" AllowDraftPRs = "allow-draft-prs" PortFlag = "port" @@ -381,6 +383,10 @@ var intFlags = map[string]intFlag{ description: "Max size of the wait group that runs parallel plans and applies (if enabled).", defaultValue: DefaultParallelPoolSize, }, + MaxProjectsPerPR: { + description: "Max number of projects to operate on in a given pull request.", + defaultValue: events.InfiniteProjectsPerPR, + }, PortFlag: { description: "Port to bind to.", defaultValue: DefaultPort, @@ -624,6 +630,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.TFEHostname == "" { c.TFEHostname = DefaultTFEHostname } + if c.MaxProjectsPerPR == 0 { + c.MaxProjectsPerPR = events.InfiniteProjectsPerPR + } } func (s *ServerCmd) validate(userConfig server.UserConfig) error { diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 732ce61103..4e2251d3a1 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -29,6 +29,9 @@ const ( DefaultParallelPlanEnabled = false // DefaultDeleteSourceBranchOnMerge being false is the default setting whether or not to remove a source branch on merge DefaultDeleteSourceBranchOnMerge = false + // InfiniteProjectLimitPerPR is the default setting for number of projects per PR. + // this is set to -1 to signify no limit. + InfiniteProjectsPerPR = -1 ) func NewProjectCommandBuilder( @@ -47,7 +50,43 @@ func NewProjectCommandBuilder( scope stats.Scope, logger logging.SimpleLogging, ) ProjectCommandBuilder { - projectCommandBuilder := &DefaultProjectCommandBuilder{ + return NewProjectCommandBuilderWithLimit( + policyChecksSupported, + parserValidator, + projectFinder, + vcsClient, + workingDir, + workingDirLocker, + globalCfg, + pendingPlanFinder, + commentBuilder, + skipCloneNoChanges, + EnableRegExpCmd, + AutoplanFileList, + scope, + logger, + InfiniteProjectsPerPR, + ) +} + +func NewProjectCommandBuilderWithLimit( + policyChecksSupported bool, + parserValidator *yaml.ParserValidator, + projectFinder ProjectFinder, + vcsClient vcs.Client, + workingDir WorkingDir, + workingDirLocker WorkingDirLocker, + globalCfg valid.GlobalCfg, + pendingPlanFinder *DefaultPendingPlanFinder, + commentBuilder CommentBuilder, + skipCloneNoChanges bool, + EnableRegExpCmd bool, + AutoplanFileList string, + scope stats.Scope, + logger logging.SimpleLogging, + limit int, +) ProjectCommandBuilder { + var projectCommandBuilder ProjectCommandBuilder = &DefaultProjectCommandBuilder{ ParserValidator: parserValidator, ProjectFinder: projectFinder, VCSClient: vcsClient, @@ -65,6 +104,11 @@ func NewProjectCommandBuilder( ), } + projectCommandBuilder = &SizeLimitedProjectCommandBuilder{ + Limit: limit, + ProjectCommandBuilder: projectCommandBuilder, + } + return &InstrumentedProjectCommandBuilder{ ProjectCommandBuilder: projectCommandBuilder, Logger: logger, diff --git a/server/events/size_limited_project_command_builder.go b/server/events/size_limited_project_command_builder.go new file mode 100644 index 0000000000..984a9ecf41 --- /dev/null +++ b/server/events/size_limited_project_command_builder.go @@ -0,0 +1,48 @@ +package events + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" +) + +type SizeLimitedProjectCommandBuilder struct { + Limit int + ProjectCommandBuilder +} + +func (b *SizeLimitedProjectCommandBuilder) BuildAutoplanCommands(ctx *CommandContext) ([]models.ProjectCommandContext, error) { + projects, err := b.ProjectCommandBuilder.BuildAutoplanCommands(ctx) + + if err != nil { + return projects, err + } + + return projects, b.CheckAgainstLimit(projects) +} + +func (b *SizeLimitedProjectCommandBuilder) BuildPlanCommands(ctx *CommandContext, comment *CommentCommand) ([]models.ProjectCommandContext, error) { + projects, err := b.ProjectCommandBuilder.BuildPlanCommands(ctx, comment) + + if err != nil { + return projects, err + } + + return projects, b.CheckAgainstLimit(projects) +} + +func (b *SizeLimitedProjectCommandBuilder) CheckAgainstLimit(projects []models.ProjectCommandContext) error { + if b.Limit != InfiniteProjectsPerPR && len(projects) > b.Limit { + return errors.New( + fmt.Sprintf( + "Number of projects cannot exceed %d. This can either be caused by:\n"+ + "1) GH failure in recognizing the diff\n"+ + "2) Pull Request batch is too large for the given Atlantis instance\n\n"+ + "Please break this pull request into smaller batches and try again.", + b.Limit, + ), + ) + } + return nil +} diff --git a/server/events/size_limited_project_command_builder_test.go b/server/events/size_limited_project_command_builder_test.go new file mode 100644 index 0000000000..36f58a7107 --- /dev/null +++ b/server/events/size_limited_project_command_builder_test.go @@ -0,0 +1,143 @@ +package events_test + +import ( + "testing" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/mocks" + "github.com/runatlantis/atlantis/server/events/models" + . "github.com/runatlantis/atlantis/testing" +) + +func TestSizeLimitedProjectCommandBuilder_autoplan(t *testing.T) { + RegisterMockTestingT(t) + + delegate := mocks.NewMockProjectCommandBuilder() + + ctx := &events.CommandContext{} + + project1 := models.ProjectCommandContext{ + ProjectName: "test1", + } + + project2 := models.ProjectCommandContext{ + ProjectName: "test2", + } + + expectedResult := []models.ProjectCommandContext{project1, project2} + + t.Run("Limit Defined and Breached", func(t *testing.T) { + subject := &events.SizeLimitedProjectCommandBuilder{ + Limit: 1, + ProjectCommandBuilder: delegate, + } + + When(delegate.BuildAutoplanCommands(ctx)).ThenReturn(expectedResult, nil) + + _, err := subject.BuildAutoplanCommands(ctx) + + ErrEquals(t, `Number of projects cannot exceed 1. This can either be caused by: +1) GH failure in recognizing the diff +2) Pull Request batch is too large for the given Atlantis instance + +Please break this pull request into smaller batches and try again.`, err) + }) + + t.Run("Limit defined and not breached", func(t *testing.T) { + subject := &events.SizeLimitedProjectCommandBuilder{ + Limit: 2, + ProjectCommandBuilder: delegate, + } + + When(delegate.BuildAutoplanCommands(ctx)).ThenReturn(expectedResult, nil) + + result, err := subject.BuildAutoplanCommands(ctx) + + Ok(t, err) + + Assert(t, len(result) == len(expectedResult), "size is expected") + }) + + t.Run("Limit not defined", func(t *testing.T) { + subject := &events.SizeLimitedProjectCommandBuilder{ + Limit: events.InfiniteProjectsPerPR, + ProjectCommandBuilder: delegate, + } + + When(delegate.BuildAutoplanCommands(ctx)).ThenReturn(expectedResult, nil) + + result, err := subject.BuildAutoplanCommands(ctx) + + Ok(t, err) + + Assert(t, len(result) == len(expectedResult), "size is expected") + }) +} + +func TestSizeLimitedProjectCommandBuilder_planComment(t *testing.T) { + RegisterMockTestingT(t) + + delegate := mocks.NewMockProjectCommandBuilder() + + ctx := &events.CommandContext{} + + comment := &events.CommentCommand{} + + project1 := models.ProjectCommandContext{ + ProjectName: "test1", + } + + project2 := models.ProjectCommandContext{ + ProjectName: "test2", + } + + expectedResult := []models.ProjectCommandContext{project1, project2} + + t.Run("Limit Defined and Breached", func(t *testing.T) { + subject := &events.SizeLimitedProjectCommandBuilder{ + Limit: 1, + ProjectCommandBuilder: delegate, + } + + When(delegate.BuildPlanCommands(ctx, comment)).ThenReturn(expectedResult, nil) + + _, err := subject.BuildPlanCommands(ctx, comment) + + ErrEquals(t, `Number of projects cannot exceed 1. This can either be caused by: +1) GH failure in recognizing the diff +2) Pull Request batch is too large for the given Atlantis instance + +Please break this pull request into smaller batches and try again.`, err) + }) + + t.Run("Limit defined and not breached", func(t *testing.T) { + subject := &events.SizeLimitedProjectCommandBuilder{ + Limit: 2, + ProjectCommandBuilder: delegate, + } + + When(delegate.BuildPlanCommands(ctx, comment)).ThenReturn(expectedResult, nil) + + result, err := subject.BuildPlanCommands(ctx, comment) + + Ok(t, err) + + Assert(t, len(result) == len(expectedResult), "size is expected") + }) + + t.Run("Limit not defined", func(t *testing.T) { + subject := &events.SizeLimitedProjectCommandBuilder{ + Limit: events.InfiniteProjectsPerPR, + ProjectCommandBuilder: delegate, + } + + When(delegate.BuildPlanCommands(ctx, comment)).ThenReturn(expectedResult, nil) + + result, err := subject.BuildPlanCommands(ctx, comment) + + Ok(t, err) + + Assert(t, len(result) == len(expectedResult), "size is expected") + }) +} diff --git a/server/server.go b/server/server.go index 2bee0b5a0d..15060db269 100644 --- a/server/server.go +++ b/server/server.go @@ -426,7 +426,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { WorkingDir: workingDir, PreWorkflowHookRunner: runtime.DefaultPreWorkflowHookRunner{}, } - projectCommandBuilder := events.NewProjectCommandBuilder( + projectCommandBuilder := events.NewProjectCommandBuilderWithLimit( policyChecksEnabled, validator, &events.DefaultProjectFinder{}, @@ -441,6 +441,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { userConfig.AutoplanFileList, statsScope, logger, + userConfig.MaxProjectsPerPR, ) showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfVersion) diff --git a/server/user_config.go b/server/user_config.go index c6cdca24b7..0b23a03186 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -45,6 +45,7 @@ type UserConfig struct { HidePrevPlanComments bool `mapstructure:"hide-prev-plan-comments"` LogLevel string `mapstructure:"log-level"` ParallelPoolSize int `mapstructure:"parallel-pool-size"` + MaxProjectsPerPR int `mapstructure:"max-projects-per-pr"` StatsNamespace string `mapstructure:"stats-namespace"` PlanDrafts bool `mapstructure:"allow-draft-prs"` Port int `mapstructure:"port"`