// Copyright 2017 HootSuite Media Inc. // // 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. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "fmt" "strconv" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/google/go-github/v71/github" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" "github.com/runatlantis/atlantis/server/recovery" "github.com/runatlantis/atlantis/server/utils" tally "github.com/uber-go/tally/v4" gitlab "gitlab.com/gitlab-org/api/client-go" ) const ( ShutdownComment = "Atlantis server is shutting down, please try again later." ) //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_command_runner.go CommandRunner // CommandRunner is the first step after a command request has been parsed. type CommandRunner interface { // RunCommentCommand is the first step after a command request has been parsed. // It handles gathering additional information needed to execute the command // and then calling the appropriate services to finish executing the command. RunCommentCommand(baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, cmd *CommentCommand) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_github_pull_getter.go GithubPullGetter // GithubPullGetter makes API calls to get pull requests. type GithubPullGetter interface { // GetPullRequest gets the pull request with id pullNum for the repo. GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*github.PullRequest, error) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_azuredevops_pull_getter.go AzureDevopsPullGetter // AzureDevopsPullGetter makes API calls to get pull requests. type AzureDevopsPullGetter interface { // GetPullRequest gets the pull request with id pullNum for the repo. GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*azuredevops.GitPullRequest, error) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_gitlab_merge_request_getter.go GitlabMergeRequestGetter // GitlabMergeRequestGetter makes API calls to get merge requests. type GitlabMergeRequestGetter interface { // GetMergeRequest gets the pull request with the id pullNum for the repo. GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*gitlab.MergeRequest, error) } // CommentCommandRunner runs individual command workflows. type CommentCommandRunner interface { Run(*command.Context, *CommentCommand) } func buildCommentCommandRunner( cmdRunner *DefaultCommandRunner, cmdName command.Name, ) CommentCommandRunner { // panic here, we want to fail fast and hard since // this would be an internal service configuration error. runner, ok := cmdRunner.CommentCommandRunnerByCmd[cmdName] if !ok { panic(fmt.Sprintf("command runner not configured for command %s", cmdName.String())) } return runner } // DefaultCommandRunner is the first step when processing a comment command. type DefaultCommandRunner struct { VCSClient vcs.Client `validate:"required"` GithubPullGetter GithubPullGetter AzureDevopsPullGetter AzureDevopsPullGetter GitlabMergeRequestGetter GitlabMergeRequestGetter GiteaPullGetter *gitea.GiteaClient // User config option: Disables autoplan when a pull request is opened or updated. DisableAutoplan bool DisableAutoplanLabel string EventParser EventParsing // User config option: Fail and do not run the Atlantis command request if any of the pre workflow hooks error FailOnPreWorkflowHookError bool Logger logging.SimpleLogging `validate:"required"` GlobalCfg valid.GlobalCfg `validate:"required"` StatsScope tally.Scope `validate:"required"` // User config option: controls whether to operate on pull requests from forks. AllowForkPRs bool // ParallelPoolSize controls the size of the wait group used to run // parallel plans and applies (if enabled). ParallelPoolSize int // AllowForkPRsFlag is the name of the flag that controls fork PR's. We use // this in our error message back to the user on a forked PR so they know // how to enable this functionality. AllowForkPRsFlag string // User config option: controls whether to comment on Fork PRs when AllowForkPRs = False SilenceForkPRErrors bool // SilenceForkPRErrorsFlag is the name of the flag that controls fork PR's. We use // this in our error message back to the user on a forked PR so they know // how to disable error comment SilenceForkPRErrorsFlag string CommentCommandRunnerByCmd map[command.Name]CommentCommandRunner `validate:"required"` Drainer *Drainer `validate:"required"` PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner `validate:"required"` PostWorkflowHooksCommandRunner PostWorkflowHooksCommandRunner `validate:"required"` PullStatusFetcher PullStatusFetcher `validate:"required"` TeamAllowlistChecker command.TeamAllowlistChecker `validate:"required"` VarFileAllowlistChecker *VarFileAllowlistChecker `validate:"required"` CommitStatusUpdater CommitStatusUpdater `validate:"required"` } // RunAutoplanCommand runs plan and policy_checks when a pull request is opened or updated. func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) { if opStarted := c.Drainer.StartOp(); !opStarted { if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pull.Num, ShutdownComment, command.Plan.String()); commentErr != nil { c.Logger.Log(logging.Error, "unable to comment that Atlantis is shutting down: %s", commentErr) } return } defer c.Drainer.OpDone() log := c.buildLogger(baseRepo.FullName, pull.Num) defer c.logPanics(baseRepo, pull.Num, log) status, err := c.PullStatusFetcher.GetPullStatus(pull) if err != nil { log.Err("Unable to fetch pull status, this is likely a bug.", err) } scope := c.StatsScope.SubScope("autoplan") timer := scope.Timer(metrics.ExecutionTimeMetric).Start() defer timer.Stop() // Check if the user who triggered the autoplan has permissions to run 'plan'. if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { err := c.fetchUserTeams(log, baseRepo, &user) if err != nil { log.Err("Unable to fetch user teams: %s", err) return } ok, err := c.checkUserPermissions(baseRepo, user, "plan") if err != nil { log.Err("Unable to check user permissions: %s", err) return } if !ok { return } } ctx := &command.Context{ User: user, Log: log, Scope: scope, Pull: pull, HeadRepo: headRepo, PullStatus: status, Trigger: command.AutoTrigger, } if !c.validateCtxAndComment(ctx, command.Autoplan) { return } if c.DisableAutoplan { return } if len(c.DisableAutoplanLabel) > 0 { labels, err := c.VCSClient.GetPullLabels(ctx.Log, baseRepo, pull) if err != nil { ctx.Log.Err("Unable to get VCS pull/merge request labels: %s. Proceeding with autoplan.", err) } else if utils.SlicesContains(labels, c.DisableAutoplanLabel) { ctx.Log.Info("Pull/merge request has disable auto plan label '%s' so not running autoplan.", c.DisableAutoplanLabel) return } } ctx.Log.Info("Running autoplan...") cmd := &CommentCommand{ Name: command.Autoplan, } // Update the combined plan commit status to pending if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { ctx.Log.Warn("unable to update plan commit status: %s", err) } err = c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) if err != nil { if c.FailOnPreWorkflowHookError { ctx.Log.Err("'fail-on-pre-workflow-hook-error' set, so not running %s command.", command.Plan) // Update the plan or apply commit status to failed switch cmd.Name { case command.Plan: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); err != nil { ctx.Log.Warn("Unable to update plan commit status: %s", err) } case command.Apply: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Apply); err != nil { ctx.Log.Warn("Unable to update apply commit status: %s", err) } } return } ctx.Log.Err("'fail-on-pre-workflow-hook-error' not set so running %s command.", command.Plan) } autoPlanRunner := buildCommentCommandRunner(c, command.Plan) autoPlanRunner.Run(ctx, nil) c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cmd) // nolint: errcheck } // commentUserDoesNotHavePermissions comments on the pull request that the user // is not allowed to execute the command. func (c *DefaultCommandRunner) commentUserDoesNotHavePermissions(baseRepo models.Repo, pullNum int, user models.User, cmd *CommentCommand) { errMsg := fmt.Sprintf("```\nError: User @%s does not have permissions to execute '%s' command.\n```", user.Username, cmd.Name.String()) if err := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, errMsg, ""); err != nil { c.Logger.Err("unable to comment on pull request: %s", err) } } // checkUserPermissions checks if the user has permissions to execute the command func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user models.User, cmdName string) (bool, error) { if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() { // allowlist restriction is not enabled return true, nil } ctx := models.TeamAllowlistCheckerContext{ BaseRepo: repo, CommandName: cmdName, Log: c.Logger, Pull: models.PullRequest{}, User: user, Verbose: false, API: false, } ok := c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, user.Teams, cmdName) if !ok { return false, nil } return true, nil } // checkVarFilesInPlanCommandAllowlisted checks if paths in a 'plan' command are allowlisted. func (c *DefaultCommandRunner) checkVarFilesInPlanCommandAllowlisted(cmd *CommentCommand) error { if cmd == nil || cmd.CommandName() != command.Plan { return nil } return c.VarFileAllowlistChecker.Check(cmd.Flags) } // RunCommentCommand executes the command. // We take in a pointer for maybeHeadRepo because for some events there isn't // enough data to construct the Repo model and callers might want to wait until // the event is further validated before making an additional (potentially // wasteful) call to get the necessary data. func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, cmd *CommentCommand) { if opStarted := c.Drainer.StartOp(); !opStarted { if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, ShutdownComment, ""); commentErr != nil { c.Logger.Log(logging.Error, "unable to comment that Atlantis is shutting down: %s", commentErr) } return } defer c.Drainer.OpDone() log := c.buildLogger(baseRepo.FullName, pullNum) defer c.logPanics(baseRepo, pullNum, log) scope := c.StatsScope.SubScope("comment") if cmd != nil { scope = scope.SubScope(cmd.Name.String()) } timer := scope.Timer(metrics.ExecutionTimeMetric).Start() defer timer.Stop() // Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { err := c.fetchUserTeams(log, baseRepo, &user) if err != nil { c.Logger.Err("Unable to fetch user teams: %s", err) return } ok, err := c.checkUserPermissions(baseRepo, user, cmd.Name.String()) if err != nil { c.Logger.Err("Unable to check user permissions: %s", err) return } if !ok { c.commentUserDoesNotHavePermissions(baseRepo, pullNum, user, cmd) return } } // Check if the provided var files in a 'plan' command are allowlisted if err := c.checkVarFilesInPlanCommandAllowlisted(cmd); err != nil { errMsg := fmt.Sprintf("```\n%s\n```", err.Error()) if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, errMsg, ""); commentErr != nil { c.Logger.Err("unable to comment on pull request: %s", commentErr) } return } headRepo, pull, err := c.ensureValidRepoMetadata(baseRepo, maybeHeadRepo, maybePull, user, pullNum, log) if err != nil { return } status, err := c.PullStatusFetcher.GetPullStatus(pull) if err != nil { log.Err("Unable to fetch pull status, this is likely a bug.", err) } ctx := &command.Context{ User: user, Log: log, Pull: pull, PullStatus: status, HeadRepo: headRepo, Scope: scope, Trigger: command.CommentTrigger, PolicySet: cmd.PolicySet, ClearPolicyApproval: cmd.ClearPolicyApproval, TeamAllowlistChecker: c.TeamAllowlistChecker, } if !c.validateCtxAndComment(ctx, cmd.Name) { return } // Update the combined plan or apply commit status to pending switch cmd.Name { case command.Plan: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { ctx.Log.Warn("unable to update plan commit status: %s", err) } case command.Apply: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil { ctx.Log.Warn("unable to update apply commit status: %s", err) } } err = c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) if err != nil { if c.FailOnPreWorkflowHookError { ctx.Log.Err("'fail-on-pre-workflow-hook-error' set, so not running %s command.", cmd.Name.String()) // Update the plan or apply commit status to failed switch cmd.Name { case command.Plan: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); err != nil { ctx.Log.Warn("unable to update plan commit status: %s", err) } case command.Apply: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Apply); err != nil { ctx.Log.Warn("unable to update apply commit status: %s", err) } } return } ctx.Log.Err("'fail-on-pre-workflow-hook-error' not set so running %s command.", cmd.Name.String()) } cmdRunner := buildCommentCommandRunner(c, cmd.CommandName()) cmdRunner.Run(ctx, cmd) c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cmd) // nolint: errcheck } func (c *DefaultCommandRunner) getGithubData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.GithubPullGetter == nil { return models.PullRequest{}, models.Repo{}, errors.New("Atlantis not configured to support GitHub") } ghPull, err := c.GithubPullGetter.GetPullRequest(logger, baseRepo, pullNum) if err != nil { return models.PullRequest{}, models.Repo{}, errors.Wrap(err, "making pull request API call to GitHub") } pull, _, headRepo, err := c.EventParser.ParseGithubPull(logger, ghPull) if err != nil { return pull, headRepo, errors.Wrap(err, "extracting required fields from comment data") } return pull, headRepo, nil } func (c *DefaultCommandRunner) getGiteaData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.GiteaPullGetter == nil { return models.PullRequest{}, models.Repo{}, errors.New("Atlantis not configured to support Gitea") } giteaPull, err := c.GiteaPullGetter.GetPullRequest(logger, baseRepo, pullNum) if err != nil { return models.PullRequest{}, models.Repo{}, errors.Wrap(err, "making pull request API call to Gitea") } pull, _, headRepo, err := c.EventParser.ParseGiteaPull(giteaPull) if err != nil { return pull, headRepo, errors.Wrap(err, "extracting required fields from comment data") } return pull, headRepo, nil } func (c *DefaultCommandRunner) getGitlabData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, error) { if c.GitlabMergeRequestGetter == nil { return models.PullRequest{}, errors.New("Atlantis not configured to support GitLab") } mr, err := c.GitlabMergeRequestGetter.GetMergeRequest(logger, baseRepo.FullName, pullNum) if err != nil { return models.PullRequest{}, errors.Wrap(err, "making merge request API call to GitLab") } pull := c.EventParser.ParseGitlabMergeRequest(mr, baseRepo) return pull, nil } func (c *DefaultCommandRunner) getAzureDevopsData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.AzureDevopsPullGetter == nil { return models.PullRequest{}, models.Repo{}, errors.New("atlantis not configured to support Azure DevOps") } adPull, err := c.AzureDevopsPullGetter.GetPullRequest(logger, baseRepo, pullNum) if err != nil { return models.PullRequest{}, models.Repo{}, errors.Wrap(err, "making pull request API call to Azure DevOps") } pull, _, headRepo, err := c.EventParser.ParseAzureDevopsPull(adPull) if err != nil { return pull, headRepo, errors.Wrap(err, "extracting required fields from comment data") } return pull, headRepo, nil } func (c *DefaultCommandRunner) buildLogger(repoFullName string, pullNum int) logging.SimpleLogging { return c.Logger.WithHistory( "repo", repoFullName, "pull", strconv.Itoa(pullNum), ) } func (c *DefaultCommandRunner) ensureValidRepoMetadata( baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, _ models.User, pullNum int, log logging.SimpleLogging, ) (headRepo models.Repo, pull models.PullRequest, err error) { if maybeHeadRepo != nil { headRepo = *maybeHeadRepo } switch baseRepo.VCSHost.Type { case models.Github: pull, headRepo, err = c.getGithubData(log, baseRepo, pullNum) case models.Gitlab: pull, err = c.getGitlabData(log, baseRepo, pullNum) case models.BitbucketCloud, models.BitbucketServer: if maybePull == nil { err = errors.New("pull request should not be nil–this is a bug") break } pull = *maybePull case models.AzureDevops: pull, headRepo, err = c.getAzureDevopsData(log, baseRepo, pullNum) case models.Gitea: pull, headRepo, err = c.getGiteaData(log, baseRepo, pullNum) default: err = errors.New("Unknown VCS type–this is a bug") } if err != nil { log.Err(err.Error()) if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, fmt.Sprintf("`Error: %s`", err), ""); commentErr != nil { log.Err("unable to comment: %s", commentErr) } } return } func (c *DefaultCommandRunner) fetchUserTeams(logger logging.SimpleLogging, repo models.Repo, user *models.User) error { teams, err := c.VCSClient.GetTeamNamesForUser(logger, repo, *user) if err != nil { return err } user.Teams = teams return nil } func (c *DefaultCommandRunner) validateCtxAndComment(ctx *command.Context, commandName command.Name) bool { if !c.AllowForkPRs && ctx.HeadRepo.Owner != ctx.Pull.BaseRepo.Owner { if c.SilenceForkPRErrors { return false } ctx.Log.Info("command was run on a fork pull request which is disallowed") if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, fmt.Sprintf("Atlantis commands can't be run on fork pull requests. To enable, set --%s or, to disable this message, set --%s", c.AllowForkPRsFlag, c.SilenceForkPRErrorsFlag), ""); err != nil { ctx.Log.Err("unable to comment: %s", err) } return false } if ctx.Pull.State != models.OpenPullState && commandName != command.Unlock { ctx.Log.Info("command was run on closed pull request") if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, "Atlantis commands can't be run on closed pull requests", ""); err != nil { ctx.Log.Err("unable to comment: %s", err) } return false } repo := c.GlobalCfg.MatchingRepo(ctx.Pull.BaseRepo.ID()) if !repo.BranchMatches(ctx.Pull.BaseBranch) { ctx.Log.Info("command was run on a pull request which doesn't match base branches") // just ignore it to allow us to use any git workflows without malicious intentions. return false } return true } // logPanics logs and creates a comment on the pull request for panics. func (c *DefaultCommandRunner) logPanics(baseRepo models.Repo, pullNum int, logger logging.SimpleLogging) { if err := recover(); err != nil { stack := recovery.Stack(3) logger.Err("PANIC: %s\n%s", err, stack) if commentErr := c.VCSClient.CreateComment( logger, baseRepo, pullNum, fmt.Sprintf("**Error: goroutine panic. This is a bug.**\n```\n%s\n%s```", err, stack), "", ); commentErr != nil { logger.Err("unable to comment: %s", commentErr) } } } var automergeComment = `Automatically merging because all plans have been successfully applied.`