diff --git a/cmd/server.go b/cmd/server.go index 1aecb70045..76e96283c1 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -81,6 +81,7 @@ const ( SilenceAllowlistErrorsFlag = "silence-allowlist-errors" // SilenceWhitelistErrorsFlag is deprecated for SilenceAllowlistErrorsFlag. SilenceWhitelistErrorsFlag = "silence-whitelist-errors" + SkipCloneNoChanges = "skip-clone-no-changes" SlackTokenFlag = "slack-token" SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" @@ -319,6 +320,10 @@ var boolFlags = map[string]boolFlag{ " This writes secrets to disk and should only be enabled in a secure environment.", defaultValue: false, }, + SkipCloneNoChanges: { + description: "Skips cloning the PR repo if there are no projects were changed in the PR.", + defaultValue: false, + }, } var intFlags = map[string]intFlag{ PortFlag: { diff --git a/cmd/server_test.go b/cmd/server_test.go index 9569c5ac2a..c9ac658f9d 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -86,6 +86,7 @@ var testFlags = map[string]interface{}{ SilenceForkPRErrorsFlag: true, SilenceAllowlistErrorsFlag: true, SilenceVCSStatusNoPlans: true, + SkipCloneNoChanges: true, SlackTokenFlag: "slack-token", SSLCertFileFlag: "cert-file", SSLKeyFileFlag: "key-file", diff --git a/server/events/matchers/models_pullrequest.go b/server/events/matchers/models_pullrequest.go index dd1fb0d4ee..37e4780130 100644 --- a/server/events/matchers/models_pullrequest.go +++ b/server/events/matchers/models_pullrequest.go @@ -3,6 +3,7 @@ package matchers import ( "reflect" + "github.com/petergtz/pegomock" models "github.com/runatlantis/atlantis/server/events/models" ) diff --git a/server/events/matchers/models_repo.go b/server/events/matchers/models_repo.go index 418f13cfcf..e985fd3a90 100644 --- a/server/events/matchers/models_repo.go +++ b/server/events/matchers/models_repo.go @@ -3,6 +3,7 @@ package matchers import ( "reflect" + "github.com/petergtz/pegomock" models "github.com/runatlantis/atlantis/server/events/models" ) diff --git a/server/events/matchers/ptr_to_logging_simplelogger.go b/server/events/matchers/ptr_to_logging_simplelogger.go index 04c72791bc..095fa65a72 100644 --- a/server/events/matchers/ptr_to_logging_simplelogger.go +++ b/server/events/matchers/ptr_to_logging_simplelogger.go @@ -3,6 +3,7 @@ package matchers import ( "reflect" + "github.com/petergtz/pegomock" logging "github.com/runatlantis/atlantis/server/logging" ) diff --git a/server/events/mock_workingdir_test.go b/server/events/mock_workingdir_test.go index 8313ca5050..db1288f599 100644 --- a/server/events/mock_workingdir_test.go +++ b/server/events/mock_workingdir_test.go @@ -4,11 +4,12 @@ package events import ( + "reflect" + "time" + pegomock "github.com/petergtz/pegomock" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" - "reflect" - "time" ) type MockWorkingDir struct { diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 4980067aa6..007034824d 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -53,14 +53,15 @@ type ProjectCommandBuilder interface { // This class combines the data from the comment and any atlantis.yaml file or // Atlantis server config and then generates a set of contexts. type DefaultProjectCommandBuilder struct { - ParserValidator *yaml.ParserValidator - ProjectFinder ProjectFinder - VCSClient vcs.Client - WorkingDir WorkingDir - WorkingDirLocker WorkingDirLocker - GlobalCfg valid.GlobalCfg - PendingPlanFinder *DefaultPendingPlanFinder - CommentBuilder CommentBuilder + ParserValidator *yaml.ParserValidator + ProjectFinder ProjectFinder + VCSClient vcs.Client + WorkingDir WorkingDir + WorkingDirLocker WorkingDirLocker + GlobalCfg valid.GlobalCfg + PendingPlanFinder *DefaultPendingPlanFinder + CommentBuilder CommentBuilder + SkipCloneNoChanges bool } // See ProjectCommandBuilder.BuildAutoplanCommands. @@ -101,8 +102,43 @@ func (p *DefaultProjectCommandBuilder) BuildApplyCommands(ctx *CommandContext, c // buildPlanAllCommands builds plan contexts for all projects we determine were // modified in this ctx. func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, commentFlags []string, verbose bool) ([]models.ProjectCommandContext, error) { + // We'll need the list of modified files. + modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull) + if err != nil { + return nil, err + } + ctx.Log.Debug("%d files were modified in this pull request", len(modifiedFiles)) + + if p.SkipCloneNoChanges && p.VCSClient.SupportsSingleFileDownload(ctx.BaseRepo) { + hasRepoCfg, repoCfgData, err := p.VCSClient.DownloadRepoConfigFile(ctx.Pull) + if err != nil { + return nil, errors.Wrapf(err, "downloading %s", yaml.AtlantisYAMLFilename) + } + + if hasRepoCfg { + repoCfg, err := p.ParserValidator.ParseRepoCfgData(repoCfgData, p.GlobalCfg, ctx.BaseRepo.ID()) + if err != nil { + return nil, errors.Wrapf(err, "parsing %s", yaml.AtlantisYAMLFilename) + } + ctx.Log.Info("successfully parsed remote %s file", yaml.AtlantisYAMLFilename) + matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, "") + if err != nil { + return nil, err + } + ctx.Log.Info("%d projects are changed on MR %q based on their when_modified config", len(matchingProjects), ctx.Pull.Num) + if len(matchingProjects) == 0 { + ctx.Log.Info("skipping repo clone since no project was modified") + return []models.ProjectCommandContext{}, nil + } + // NOTE: We discard this work here and end up doing it again after + // cloning to ensure all the return values are set properly with + // the actual clone directory. + } + } + // Need to lock the workspace we're about to clone to. workspace := DefaultWorkspace + unlockFn, err := p.WorkingDirLocker.TryLock(ctx.BaseRepo.FullName, ctx.Pull.Num, workspace) if err != nil { ctx.Log.Warn("workspace was locked") @@ -111,18 +147,10 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, ctx.Log.Debug("got workspace lock") defer unlockFn() - // We'll need the list of modified files. - modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull) - if err != nil { - return nil, err - } - ctx.Log.Debug("%d files were modified in this pull request", len(modifiedFiles)) - repoDir, _, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, workspace) if err != nil { return nil, err } - // Parse config file if it exists. hasRepoCfg, err := p.ParserValidator.HasRepoCfg(repoDir) if err != nil { diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go index fc04e9a6c0..9adfb64606 100644 --- a/server/events/project_command_builder_internal_test.go +++ b/server/events/project_command_builder_internal_test.go @@ -563,14 +563,15 @@ projects: } builder := &DefaultProjectCommandBuilder{ - WorkingDirLocker: NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: parser, - VCSClient: vcsClient, - ProjectFinder: &DefaultProjectFinder{}, - PendingPlanFinder: &DefaultPendingPlanFinder{}, - CommentBuilder: &CommentParser{}, - GlobalCfg: globalCfg, + WorkingDirLocker: NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: parser, + VCSClient: vcsClient, + ProjectFinder: &DefaultProjectFinder{}, + PendingPlanFinder: &DefaultPendingPlanFinder{}, + CommentBuilder: &CommentParser{}, + GlobalCfg: globalCfg, + SkipCloneNoChanges: false, } // We run a test for each type of command. diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index dad6276306..50bbd51ca5 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -135,14 +135,15 @@ projects: } builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: &yaml.ParserValidator{}, - VCSClient: vcsClient, - ProjectFinder: &events.DefaultProjectFinder{}, - PendingPlanFinder: &events.DefaultPendingPlanFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(false, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: vcsClient, + ProjectFinder: &events.DefaultProjectFinder{}, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(false, false, false), + SkipCloneNoChanges: false, } ctxs, err := builder.BuildAutoplanCommands(&events.CommandContext{ @@ -358,13 +359,14 @@ projects: } builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: &yaml.ParserValidator{}, - VCSClient: vcsClient, - ProjectFinder: &events.DefaultProjectFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(true, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: vcsClient, + ProjectFinder: &events.DefaultProjectFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(true, false, false), + SkipCloneNoChanges: false, } var actCtxs []models.ProjectCommandContext @@ -491,13 +493,14 @@ projects: } builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: &yaml.ParserValidator{}, - VCSClient: vcsClient, - ProjectFinder: &events.DefaultProjectFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(true, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: vcsClient, + ProjectFinder: &events.DefaultProjectFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(true, false, false), + SkipCloneNoChanges: false, } ctxs, err := builder.BuildPlanCommands( @@ -562,14 +565,15 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { ThenReturn(tmpDir, nil) builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: &yaml.ParserValidator{}, - VCSClient: nil, - ProjectFinder: &events.DefaultProjectFinder{}, - PendingPlanFinder: &events.DefaultPendingPlanFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(false, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: nil, + ProjectFinder: &events.DefaultProjectFinder{}, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(false, false, false), + SkipCloneNoChanges: false, } ctxs, err := builder.BuildApplyCommands( @@ -630,13 +634,14 @@ projects: AnyString())).ThenReturn(repoDir, nil) builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: &yaml.ParserValidator{}, - VCSClient: nil, - ProjectFinder: &events.DefaultProjectFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(true, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: nil, + ProjectFinder: &events.DefaultProjectFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(true, false, false), + SkipCloneNoChanges: false, } ctx := &events.CommandContext{ @@ -692,13 +697,14 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) { When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{"main.tf"}, nil) builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - ParserValidator: &yaml.ParserValidator{}, - VCSClient: vcsClient, - ProjectFinder: &events.DefaultProjectFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(true, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: vcsClient, + ProjectFinder: &events.DefaultProjectFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(true, false, false), + SkipCloneNoChanges: false, } var actCtxs []models.ProjectCommandContext @@ -856,13 +862,14 @@ projects: AnyString())).ThenReturn(tmpDir, nil) builder := &events.DefaultProjectCommandBuilder{ - WorkingDirLocker: events.NewDefaultWorkingDirLocker(), - WorkingDir: workingDir, - VCSClient: vcsClient, - ParserValidator: &yaml.ParserValidator{}, - ProjectFinder: &events.DefaultProjectFinder{}, - CommentBuilder: &events.CommentParser{}, - GlobalCfg: valid.NewGlobalCfg(true, false, false), + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + VCSClient: vcsClient, + ParserValidator: &yaml.ParserValidator{}, + ProjectFinder: &events.DefaultProjectFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(true, false, false), + SkipCloneNoChanges: false, } actCtxs, err := builder.BuildPlanCommands( @@ -887,3 +894,43 @@ projects: }) } } + +// Test that we don't clone the repo if there were no changes based on the atlantis.yaml file. +func TestDefaultProjectCommandBuilder_SkipCloneNoChanges(t *testing.T) { + atlantisYAML := ` +version: 3 +projects: +- dir: dir1` + + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{"main.tf"}, nil) + When(vcsClient.SupportsSingleFileDownload(matchers.AnyModelsRepo())).ThenReturn(true) + When(vcsClient.DownloadRepoConfigFile(matchers.AnyModelsPullRequest())).ThenReturn(true, []byte(atlantisYAML), nil) + workingDir := mocks.NewMockWorkingDir() + + builder := &events.DefaultProjectCommandBuilder{ + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + WorkingDir: workingDir, + ParserValidator: &yaml.ParserValidator{}, + VCSClient: vcsClient, + ProjectFinder: &events.DefaultProjectFinder{}, + CommentBuilder: &events.CommentParser{}, + GlobalCfg: valid.NewGlobalCfg(true, false, false), + SkipCloneNoChanges: true, + } + + var actCtxs []models.ProjectCommandContext + var err error + actCtxs, err = builder.BuildAutoplanCommands(&events.CommandContext{ + BaseRepo: models.Repo{}, + HeadRepo: models.Repo{}, + Pull: models.PullRequest{}, + User: models.User{}, + Log: nil, + PullMergeable: true, + }) + Ok(t, err) + Equals(t, 0, len(actCtxs)) + workingDir.VerifyWasCalled(Never()).Clone(matchers.AnyPtrToLoggingSimpleLogger(), matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), AnyString()) +} diff --git a/server/events/project_finder.go b/server/events/project_finder.go index 6c68a1fd61..2c2dc92394 100644 --- a/server/events/project_finder.go +++ b/server/events/project_finder.go @@ -120,11 +120,22 @@ func (p *DefaultProjectFinder) DetermineProjectsViaConfig(log *logging.SimpleLog } if match { log.Debug("file %q matched pattern", file) - _, err := os.Stat(filepath.Join(absRepoDir, project.Dir)) - if err == nil { - projects = append(projects, project) + // If we're checking using an atlantis.yaml file we downloaded + // directly from the repo (when doing a no-clone check) then + // absRepoDir will be empty. Since we didn't clone the repo + // yet we can't do this check. If there was a file modified + // in a deleted directory then when we finally do clone the repo + // we'll call this function again and then we'll detect the + // directory was deleted. + if absRepoDir != "" { + _, err := os.Stat(filepath.Join(absRepoDir, project.Dir)) + if err == nil { + projects = append(projects, project) + } else { + log.Debug("project at dir %q not included because dir does not exist", project.Dir) + } } else { - log.Debug("project at dir %q not included because dir does not exist", project.Dir) + projects = append(projects, project) } break } diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 26a69a5e79..15a26526a3 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -364,3 +364,11 @@ func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project st } return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:] } + +func (g *AzureDevopsClient) SupportsSingleFileDownload(repo models.Repo) bool { + return false +} + +func (g *AzureDevopsClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + return false, []byte{}, fmt.Errorf("Not Implemented") +} diff --git a/server/events/vcs/bitbucketcloud/client.go b/server/events/vcs/bitbucketcloud/client.go index 88d6312941..ff60faf6c6 100644 --- a/server/events/vcs/bitbucketcloud/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -243,3 +243,14 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b } return respBody, nil } + +func (b *Client) SupportsSingleFileDownload(models.Repo) bool { + return false +} + +// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) +// The first return value indicate that repo contain atlantis.yaml or not +// if BaseRepo had one repo config file, its content will placed on the second return value +func (b *Client) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + return false, []byte{}, fmt.Errorf("Not Implemented") +} diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go index 0c99627375..adb6eba0d8 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -311,3 +311,14 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b } return respBody, nil } + +func (b *Client) SupportsSingleFileDownload(repo models.Repo) bool { + return false +} + +// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) +// The first return value indicate that repo contain atlantis.yaml or not +// if BaseRepo had one repo config file, its content will placed on the second return value +func (b *Client) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + return false, []byte{}, fmt.Errorf("not implemented") +} diff --git a/server/events/vcs/client.go b/server/events/vcs/client.go index 5afb69f7b4..dad6fa1901 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -38,4 +38,10 @@ type Client interface { UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error MergePull(pull models.PullRequest) error MarkdownPullLink(pull models.PullRequest) (string, error) + + // DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) + // The first return value indicate that repo contain atlantis.yaml or not + // if BaseRepo had one repo config file, its content will placed on the second return value + DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) + SupportsSingleFileDownload(repo models.Repo) bool } diff --git a/server/events/vcs/github_client.go b/server/events/vcs/github_client.go index 0b5ed38c5d..3181d06956 100644 --- a/server/events/vcs/github_client.go +++ b/server/events/vcs/github_client.go @@ -15,17 +15,19 @@ package vcs import ( "context" + "encoding/base64" "fmt" + "net/http" "strings" "time" - "github.com/runatlantis/atlantis/server/events/models" - "github.com/runatlantis/atlantis/server/events/vcs/common" - "github.com/runatlantis/atlantis/server/logging" - "github.com/Laisky/graphql" "github.com/google/go-github/v31/github" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs/common" + "github.com/runatlantis/atlantis/server/events/yaml" + "github.com/runatlantis/atlantis/server/logging" "github.com/shurcooL/githubv4" ) @@ -384,3 +386,29 @@ func (g *GithubClient) ExchangeCode(code string) (*GithubAppTemporarySecrets, er return data, err } + +// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) +// The first return value indicate that repo contain atlantis.yaml or not +// if BaseRepo had one repo config file, its content will placed on the second return value +func (g *GithubClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + opt := github.RepositoryContentGetOptions{Ref: pull.HeadBranch} + fileContent, _, resp, err := g.client.Repositories.GetContents(g.ctx, pull.BaseRepo.Owner, pull.BaseRepo.Name, yaml.AtlantisYAMLFilename, &opt) + + if resp.StatusCode == http.StatusNotFound { + return false, []byte{}, nil + } + if err != nil { + return true, []byte{}, err + } + + decodedData, err := base64.StdEncoding.DecodeString(*fileContent.Content) + if err != nil { + return true, []byte{}, err + } + + return true, decodedData, nil +} + +func (g *GithubClient) SupportsSingleFileDownload(repo models.Repo) bool { + return true +} diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go index 2b6b8e561e..b991eaba30 100644 --- a/server/events/vcs/gitlab_client.go +++ b/server/events/vcs/gitlab_client.go @@ -16,9 +16,12 @@ package vcs import ( "fmt" "net" + "net/http" "net/url" "strings" + "github.com/runatlantis/atlantis/server/events/yaml" + "github.com/runatlantis/atlantis/server/events/vcs/common" version "github.com/hashicorp/go-version" @@ -269,3 +272,25 @@ func MustConstraint(constraint string) version.Constraints { } return c } + +// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) +// The first return value indicate that repo contain atlantis.yaml or not +// if BaseRepo had one repo config file, its content will placed on the second return value +func (g *GitlabClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + opt := gitlab.GetRawFileOptions{Ref: gitlab.String(pull.HeadBranch)} + + bytes, resp, err := g.Client.RepositoryFiles.GetRawFile(pull.BaseRepo.FullName, yaml.AtlantisYAMLFilename, &opt) + if resp.StatusCode == http.StatusNotFound { + return false, []byte{}, nil + } + + if err != nil { + return true, []byte{}, err + } + + return true, bytes, nil +} + +func (g *GitlabClient) SupportsSingleFileDownload(repo models.Repo) bool { + return true +} diff --git a/server/events/vcs/mocks/matchers/slice_of_byte.go b/server/events/vcs/mocks/matchers/slice_of_byte.go new file mode 100644 index 0000000000..9a9894fa07 --- /dev/null +++ b/server/events/vcs/mocks/matchers/slice_of_byte.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + +) + +func AnySliceOfByte() []byte { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*([]byte))(nil)).Elem())) + var nullValue []byte + return nullValue +} + +func EqSliceOfByte(value []byte) []byte { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue []byte + return nullValue +} diff --git a/server/events/vcs/mocks/mock_client.go b/server/events/vcs/mocks/mock_client.go index 4a0e9a669b..0b20b49b63 100644 --- a/server/events/vcs/mocks/mock_client.go +++ b/server/events/vcs/mocks/mock_client.go @@ -161,6 +161,44 @@ func (mock *MockClient) MarkdownPullLink(pull models.PullRequest) (string, error return ret0, ret1 } +func (mock *MockClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockClient().") + } + params := []pegomock.Param{pull} + result := pegomock.GetGenericMockFrom(mock).Invoke("DownloadRepoConfigFile", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*[]byte)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 bool + var ret1 []byte + var ret2 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(bool) + } + if result[1] != nil { + ret1 = result[1].([]byte) + } + if result[2] != nil { + ret2 = result[2].(error) + } + } + return ret0, ret1, ret2 +} + +func (mock *MockClient) SupportsSingleFileDownload(repo models.Repo) bool { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockClient().") + } + params := []pegomock.Param{repo} + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsSingleFileDownload", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) + var ret0 bool + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(bool) + } + } + return ret0 +} + func (mock *MockClient) VerifyWasCalledOnce() *VerifierMockClient { return &VerifierMockClient{ mock: mock, @@ -461,3 +499,57 @@ func (c *MockClient_MarkdownPullLink_OngoingVerification) GetAllCapturedArgument } return } + +func (verifier *VerifierMockClient) DownloadRepoConfigFile(pull models.PullRequest) *MockClient_DownloadRepoConfigFile_OngoingVerification { + params := []pegomock.Param{pull} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DownloadRepoConfigFile", params, verifier.timeout) + return &MockClient_DownloadRepoConfigFile_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockClient_DownloadRepoConfigFile_OngoingVerification struct { + mock *MockClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockClient_DownloadRepoConfigFile_OngoingVerification) GetCapturedArguments() models.PullRequest { + pull := c.GetAllCapturedArguments() + return pull[len(pull)-1] +} + +func (c *MockClient_DownloadRepoConfigFile_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.PullRequest, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.PullRequest) + } + } + return +} + +func (verifier *VerifierMockClient) SupportsSingleFileDownload(repo models.Repo) *MockClient_SupportsSingleFileDownload_OngoingVerification { + params := []pegomock.Param{repo} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsSingleFileDownload", params, verifier.timeout) + return &MockClient_SupportsSingleFileDownload_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockClient_SupportsSingleFileDownload_OngoingVerification struct { + mock *MockClient + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockClient_SupportsSingleFileDownload_OngoingVerification) GetCapturedArguments() models.Repo { + repo := c.GetAllCapturedArguments() + return repo[len(repo)-1] +} + +func (c *MockClient_SupportsSingleFileDownload_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.Repo, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.Repo) + } + } + return +} diff --git a/server/events/vcs/not_configured_vcs_client.go b/server/events/vcs/not_configured_vcs_client.go index c8fe20ed9d..7ce68a3a03 100644 --- a/server/events/vcs/not_configured_vcs_client.go +++ b/server/events/vcs/not_configured_vcs_client.go @@ -53,3 +53,11 @@ func (a *NotConfiguredVCSClient) MarkdownPullLink(pull models.PullRequest) (stri func (a *NotConfiguredVCSClient) err() error { return fmt.Errorf("atlantis was not configured to support repos from %s", a.Host.String()) } + +func (a *NotConfiguredVCSClient) SupportsSingleFileDownload(repo models.Repo) bool { + return false +} + +func (a *NotConfiguredVCSClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + return true, []byte{}, a.err() +} diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index 6511bf2d99..a583d70cc0 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -83,3 +83,11 @@ func (d *ClientProxy) MergePull(pull models.PullRequest) error { func (d *ClientProxy) MarkdownPullLink(pull models.PullRequest) (string, error) { return d.clients[pull.BaseRepo.VCSHost.Type].MarkdownPullLink(pull) } + +func (d *ClientProxy) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { + return d.clients[pull.BaseRepo.VCSHost.Type].DownloadRepoConfigFile(pull) +} + +func (d *ClientProxy) SupportsSingleFileDownload(repo models.Repo) bool { + return d.clients[repo.VCSHost.Type].SupportsSingleFileDownload(repo) +} diff --git a/server/events/yaml/parser_validator.go b/server/events/yaml/parser_validator.go index 0e2bd84a24..d21b221ccd 100644 --- a/server/events/yaml/parser_validator.go +++ b/server/events/yaml/parser_validator.go @@ -56,9 +56,12 @@ func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.Global // able to detect if it's a NotExist err. return valid.RepoCfg{}, err } + return p.ParseRepoCfgData(configData, globalCfg, repoID) +} +func (p *ParserValidator) ParseRepoCfgData(repoCfgData []byte, globalCfg valid.GlobalCfg, repoID string) (valid.RepoCfg, error) { var rawConfig raw.RepoCfg - if err := yaml.UnmarshalStrict(configData, &rawConfig); err != nil { + if err := yaml.UnmarshalStrict(repoCfgData, &rawConfig); err != nil { return valid.RepoCfg{}, err } @@ -83,7 +86,7 @@ func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.Global } } - err = globalCfg.ValidateRepoCfg(validConfig, repoID) + err := globalCfg.ValidateRepoCfg(validConfig, repoID) return validConfig, err } diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index ee4929a747..d3f51d9735 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -476,14 +476,15 @@ func setupE2E(t *testing.T, repoDir string) (server.EventsController, *vcsmocks. AllowForkPRs: allowForkPRs, AllowForkPRsFlag: "allow-fork-prs", ProjectCommandBuilder: &events.DefaultProjectCommandBuilder{ - ParserValidator: parser, - ProjectFinder: &events.DefaultProjectFinder{}, - VCSClient: e2eVCSClient, - WorkingDir: workingDir, - WorkingDirLocker: locker, - PendingPlanFinder: &events.DefaultPendingPlanFinder{}, - CommentBuilder: commentParser, - GlobalCfg: globalCfg, + ParserValidator: parser, + ProjectFinder: &events.DefaultProjectFinder{}, + VCSClient: e2eVCSClient, + WorkingDir: workingDir, + WorkingDirLocker: locker, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, + CommentBuilder: commentParser, + GlobalCfg: globalCfg, + SkipCloneNoChanges: false, }, DB: boltdb, PendingPlanFinder: &events.DefaultPendingPlanFinder{}, diff --git a/server/server.go b/server/server.go index 9a33e703ac..18983cceb4 100644 --- a/server/server.go +++ b/server/server.go @@ -370,14 +370,15 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { SilenceVCSStatusNoPlans: userConfig.SilenceVCSStatusNoPlans, DisableApplyAll: userConfig.DisableApplyAll, ProjectCommandBuilder: &events.DefaultProjectCommandBuilder{ - ParserValidator: validator, - ProjectFinder: &events.DefaultProjectFinder{}, - VCSClient: vcsClient, - WorkingDir: workingDir, - WorkingDirLocker: workingDirLocker, - GlobalCfg: globalCfg, - PendingPlanFinder: pendingPlanFinder, - CommentBuilder: commentParser, + ParserValidator: validator, + ProjectFinder: &events.DefaultProjectFinder{}, + VCSClient: vcsClient, + WorkingDir: workingDir, + WorkingDirLocker: workingDirLocker, + GlobalCfg: globalCfg, + PendingPlanFinder: pendingPlanFinder, + CommentBuilder: commentParser, + SkipCloneNoChanges: userConfig.SkipCloneNoChanges, }, ProjectCommandRunner: &events.DefaultProjectCommandRunner{ Locker: projectLocker, diff --git a/server/user_config.go b/server/user_config.go index 04acc6d6f0..c7e2d4b4f5 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -58,6 +58,7 @@ type UserConfig struct { SilenceAllowlistErrors bool `mapstructure:"silence-allowlist-errors"` // SilenceWhitelistErrors is deprecated in favour of SilenceAllowlistErrors SilenceWhitelistErrors bool `mapstructure:"silence-whitelist-errors"` + SkipCloneNoChanges bool `mapstructure:"skip-clone-no-changes"` SlackToken string `mapstructure:"slack-token"` SSLCertFile string `mapstructure:"ssl-cert-file"` SSLKeyFile string `mapstructure:"ssl-key-file"`