From aae2fd7c2b1e1b4137fba5ee2b4e6515895fbfdc Mon Sep 17 00:00:00 2001 From: X-Guardian <32168619+X-Guardian@users.noreply.github.com> Date: Sat, 1 Feb 2025 16:35:36 +0000 Subject: [PATCH] Format fix Signed-off-by: X-Guardian <32168619+X-Guardian@users.noreply.github.com> --- server/events/apply_command_runner.copy | 197 ++++ server/events/command_runner.copy | 602 ++++++++++ server/events/pull_closed_executor_test.go | 2 +- server/metrics/debug.go | 12 +- server/server.copy | 1247 ++++++++++++++++++++ 5 files changed, 2053 insertions(+), 7 deletions(-) create mode 100644 server/events/apply_command_runner.copy create mode 100644 server/events/command_runner.copy create mode 100644 server/server.copy diff --git a/server/events/apply_command_runner.copy b/server/events/apply_command_runner.copy new file mode 100644 index 0000000000..d6e9d38388 --- /dev/null +++ b/server/events/apply_command_runner.copy @@ -0,0 +1,197 @@ +package events + +import ( + "github.com/runatlantis/atlantis/server/core/locking" + "github.com/runatlantis/atlantis/server/events/command" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" +) + +func NewApplyCommandRunner( + vcsClient vcs.Client, + disableApplyAll bool, + applyCommandLocker locking.ApplyLockChecker, + commitStatusUpdater CommitStatusUpdater, + prjCommandBuilder ProjectApplyCommandBuilder, + prjCmdRunner ProjectApplyCommandRunner, + autoMerger *AutoMerger, + pullUpdater *PullUpdater, + dbUpdater *DBUpdater, + backend locking.Backend, + parallelPoolSize int, + SilenceNoProjects bool, + silenceVCSStatusNoProjects bool, + pullReqStatusFetcher vcs.PullReqStatusFetcher, +) *ApplyCommandRunner { + return &ApplyCommandRunner{ + vcsClient: vcsClient, + DisableApplyAll: disableApplyAll, + locker: applyCommandLocker, + commitStatusUpdater: commitStatusUpdater, + prjCmdBuilder: prjCommandBuilder, + prjCmdRunner: prjCmdRunner, + autoMerger: autoMerger, + pullUpdater: pullUpdater, + dbUpdater: dbUpdater, + Backend: backend, + parallelPoolSize: parallelPoolSize, + SilenceNoProjects: SilenceNoProjects, + silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, + pullReqStatusFetcher: pullReqStatusFetcher, + } +} + +type ApplyCommandRunner struct { + DisableApplyAll bool + Backend locking.Backend + locker locking.ApplyLockChecker + vcsClient vcs.Client + commitStatusUpdater CommitStatusUpdater + prjCmdBuilder ProjectApplyCommandBuilder + prjCmdRunner ProjectApplyCommandRunner + autoMerger *AutoMerger + pullUpdater *PullUpdater + dbUpdater *DBUpdater + parallelPoolSize int + pullReqStatusFetcher vcs.PullReqStatusFetcher + // SilenceNoProjects is whether Atlantis should respond to PRs if no projects + // are found + SilenceNoProjects bool + // SilenceVCSStatusNoPlans is whether any plan should set commit status if no projects + // are found + silenceVCSStatusNoProjects bool + SilencePRComments []string +} + +func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { + var err error + baseRepo := ctx.Pull.BaseRepo + pull := ctx.Pull + + // Get the mergeable status before we set any build statuses of our own. + // We do this here because when we set a "Pending" status, if users have + // required the Atlantis status checks to pass, then we've now changed + // the mergeability status of the pull request. + // This sets the approved, mergeable, and sqlocked status in the context. + ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull) + if err != nil { + // On error we continue the request with mergeable assumed false. + // We want to continue because not all apply's will need this status, + // only if they rely on the mergeability requirement. + // All PullRequestStatus fields are set to false by default when error. + ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) + } + + var projectCmds []command.ProjectContext + projectCmds, err = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd) + + if err != nil { + if statusErr := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, cmd.CommandName()); statusErr != nil { + ctx.Log.Warn("unable to update commit status: %s", statusErr) + } + a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err}) + return + } + + // If there are no projects to apply, don't respond to the PR and ignore + if len(projectCmds) == 0 && a.SilenceNoProjects { + ctx.Log.Info("determined there was no project to run plan in") + if !a.silenceVCSStatusNoProjects { + if cmd.IsForSpecificProject() { + // With a specific apply, just reset the status so it's not stuck in pending state + pullStatus, err := a.Backend.GetPullStatus(pull) + if err != nil { + ctx.Log.Warn("unable to fetch pull status: %s", err) + return + } + if pullStatus == nil { + // default to 0/0 + ctx.Log.Debug("setting VCS status to 0/0 success as no previous state was found") + if err := a.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } + return + } + ctx.Log.Debug("resetting VCS status") + a.updateCommitStatus(ctx, *pullStatus) + } else { + // With a generic apply, we set successful commit statuses + // with 0/0 projects planned successfully because some users require + // the Atlantis status to be passing for all pull requests. + // Does not apply to skipped runs for specific projects + ctx.Log.Debug("setting VCS status to success with no projects found") + if err := a.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } + } + } + return + } + + // Only run commands in parallel if enabled + var result command.Result + if a.isParallelEnabled(projectCmds) { + ctx.Log.Info("Running applies in parallel") + result = runProjectCmdsParallelGroups(ctx, projectCmds, a.prjCmdRunner.Apply, a.parallelPoolSize) + } else { + result = runProjectCmds(projectCmds, a.prjCmdRunner.Apply) + } + + a.pullUpdater.updatePull( + ctx, + cmd, + result) + + pullStatus, err := a.dbUpdater.updateDB(ctx, pull, result.ProjectResults) + if err != nil { + ctx.Log.Err("writing results: %s", err) + return + } + + a.updateCommitStatus(ctx, pullStatus) + + if a.autoMerger.automergeEnabled(projectCmds) && !cmd.AutoMergeDisabled { + a.autoMerger.automerge(ctx, pullStatus, a.autoMerger.deleteSourceBranchOnMergeEnabled(projectCmds), cmd.AutoMergeMethod) + } +} + +func (a *ApplyCommandRunner) isParallelEnabled(projectCmds []command.ProjectContext) bool { + return len(projectCmds) > 0 && projectCmds[0].ParallelApplyEnabled +} + +func (a *ApplyCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus) { + var numSuccess int + var numErrored int + status := models.SuccessCommitStatus + + numSuccess = pullStatus.StatusCount(models.AppliedPlanStatus) + pullStatus.StatusCount(models.PlannedNoChangesPlanStatus) + numErrored = pullStatus.StatusCount(models.ErroredApplyStatus) + + if numErrored > 0 { + status = models.FailedCommitStatus + } else if numSuccess < len(pullStatus.Projects) { + // If there are plans that haven't been applied yet, we'll use a pending + // status. + status = models.PendingCommitStatus + } + + if err := a.commitStatusUpdater.UpdateCombinedCount( + ctx.Log, + ctx.Pull.BaseRepo, + ctx.Pull, + status, + command.Apply, + numSuccess, + len(pullStatus.Projects), + ); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } +} + +// applyAllDisabledComment is posted when apply all commands (i.e. "atlantis apply") +// are disabled and an apply all command is issued. +var applyAllDisabledComment = "**Error:** Running `atlantis apply` without flags is disabled." + + " You must specify which project to apply via the `-d `, `-w ` or `-p ` flags." + +// applyDisabledComment is posted when apply commands are disabled globally and an apply command is issued. +var applyDisabledComment = "**Error:** Running `atlantis apply` is disabled." diff --git a/server/events/command_runner.copy b/server/events/command_runner.copy new file mode 100644 index 0000000000..b975a8fee7 --- /dev/null +++ b/server/events/command_runner.copy @@ -0,0 +1,602 @@ +// 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/google/go-github/v67/github" + "github.com/mcdafydd/go-azuredevops/azuredevops" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/config/valid" + "github.com/runatlantis/atlantis/server/core/locking" + "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 "github.com/xanzy/go-gitlab" +) + +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 + 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 + GlobalCfg valid.GlobalCfg + StatsScope tally.Scope + // 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 + Drainer *Drainer + PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner + PostWorkflowHooksCommandRunner PostWorkflowHooksCommandRunner + PullStatusFetcher PullStatusFetcher + TeamAllowlistChecker command.TeamAllowlistChecker + VarFileAllowlistChecker *VarFileAllowlistChecker + CommitStatusUpdater CommitStatusUpdater + ApplyLocker locking.ApplyLockChecker +} + +// 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(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(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 + } + + if cmd.Name == command.Apply { + locked, err := c.IsApplyLocked() + // CheckApplyLock falls back to AllowedCommand flag if fetching the lock + // raises an error + // We will log failure as warning + if err != nil { + ctx.Log.Warn("checking global apply lock: %s", err) + } + + if locked { + ctx.Log.Info("ignoring apply command since apply disabled globally") + if err := c.VCSClient.CreateComment(ctx.Log, baseRepo, pull.Num, applyDisabledComment, command.Apply.String()); err != nil { + ctx.Log.Err("unable to comment on pull request: %s", err) + } + return + } + + if c.CommentCommandRunnerByCmd[command.Apply].(*ApplyCommandRunner).DisableApplyAll && !cmd.IsForSpecificProject() { + ctx.Log.Info("ignoring apply command without flags since apply all is disabled") + if err := c.VCSClient.CreateComment(ctx.Log, baseRepo, pull.Num, applyAllDisabledComment, command.Apply.String()); err != nil { + ctx.Log.Err("unable to comment on pull request: %s", err) + } + 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(repo models.Repo, user *models.User) error { + teams, err := c.VCSClient.GetTeamNamesForUser(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) + } + } +} + +func (c *DefaultCommandRunner) IsApplyLocked() (bool, error) { + lock, err := c.ApplyLocker.CheckApplyLock() + + return lock.Locked, err +} + +var automergeComment = `Automatically merging because all plans have been successfully applied.` diff --git a/server/events/pull_closed_executor_test.go b/server/events/pull_closed_executor_test.go index 583bd4e078..add595ea67 100644 --- a/server/events/pull_closed_executor_test.go +++ b/server/events/pull_closed_executor_test.go @@ -194,7 +194,7 @@ func TestCleanUpPullComments(t *testing.T) { t.Cleanup(func() { db.Close() }) - Ok(t, err) + Ok(t, err) pce := events.PullClosedExecutor{ Locker: l, VCSClient: cp, diff --git a/server/metrics/debug.go b/server/metrics/debug.go index 08203e1599..112d79b4e8 100644 --- a/server/metrics/debug.go +++ b/server/metrics/debug.go @@ -38,18 +38,18 @@ func (r *debugReporter) Flush() { } func (r *debugReporter) ReportCounter(name string, tags map[string]string, value int64) { - log := r.log.With("name", name, "value", value, "tags", tags, "type", "counter") - log.Debug("counter") + //log := r.log.With("name", name, "value", value, "tags", tags, "type", "counter") + //log.Debug("counter") } func (r *debugReporter) ReportGauge(name string, tags map[string]string, value float64) { - log := r.log.With("name", name, "value", value, "tags", tags, "type", "gauge") - log.Debug("gauge") + //log := r.log.With("name", name, "value", value, "tags", tags, "type", "gauge") + //log.Debug("gauge") } func (r *debugReporter) ReportTimer(name string, tags map[string]string, interval time.Duration) { - log := r.log.With("name", name, "value", interval, "tags", tags, "type", "timer") - log.Debug("timer") + //log := r.log.With("name", name, "value", interval, "tags", tags, "type", "timer") + //log.Debug("timer") } func (r *debugReporter) ReportHistogramValueSamples( diff --git a/server/server.copy b/server/server.copy new file mode 100644 index 0000000000..aadce43534 --- /dev/null +++ b/server/server.copy @@ -0,0 +1,1247 @@ +// 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 server handles the web server and executing commands that come in +// via webhooks. +package server + +import ( + "context" + "crypto/tls" + "embed" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + + "github.com/mitchellh/go-homedir" + tally "github.com/uber-go/tally/v4" + prometheus "github.com/uber-go/tally/v4/prometheus" + "github.com/urfave/negroni/v3" + + cfg "github.com/runatlantis/atlantis/server/core/config" + "github.com/runatlantis/atlantis/server/core/config/valid" + "github.com/runatlantis/atlantis/server/core/db" + "github.com/runatlantis/atlantis/server/core/redis" + "github.com/runatlantis/atlantis/server/jobs" + "github.com/runatlantis/atlantis/server/metrics" + "github.com/runatlantis/atlantis/server/scheduled" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/controllers" + events_controllers "github.com/runatlantis/atlantis/server/controllers/events" + "github.com/runatlantis/atlantis/server/controllers/web_templates" + "github.com/runatlantis/atlantis/server/controllers/websocket" + "github.com/runatlantis/atlantis/server/core/locking" + "github.com/runatlantis/atlantis/server/core/runtime" + "github.com/runatlantis/atlantis/server/core/runtime/policy" + "github.com/runatlantis/atlantis/server/core/terraform" + "github.com/runatlantis/atlantis/server/events" + "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/bitbucketcloud" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" + "github.com/runatlantis/atlantis/server/events/vcs/gitea" + "github.com/runatlantis/atlantis/server/events/webhooks" + "github.com/runatlantis/atlantis/server/logging" +) + +const ( + // LockViewRouteName is the named route in mux.Router for the lock view. + // The route can be retrieved by this name, ex: + // mux.Router.Get(LockViewRouteName) + LockViewRouteName = "lock-detail" + // LockViewRouteIDQueryParam is the query parameter needed to construct the lock view + // route. ex: + // mux.Router.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id") + LockViewRouteIDQueryParam = "id" + // ProjectJobsViewRouteName is the named route in mux.Router for the log stream view. + ProjectJobsViewRouteName = "project-jobs-detail" + // binDirName is the name of the directory inside our data dir where + // we download binaries. + BinDirName = "bin" + // terraformPluginCacheDir is the name of the dir inside our data dir + // where we tell terraform to cache plugins and modules. + TerraformPluginCacheDirName = "plugin-cache" +) + +// Server runs the Atlantis web server. +type Server struct { + AtlantisVersion string + AtlantisURL *url.URL + Router *mux.Router + Port int + PostWorkflowHooksCommandRunner *events.DefaultPostWorkflowHooksCommandRunner + PreWorkflowHooksCommandRunner *events.DefaultPreWorkflowHooksCommandRunner + CommandRunner *events.DefaultCommandRunner + Logger logging.SimpleLogging + StatsScope tally.Scope + StatsReporter tally.BaseStatsReporter + StatsCloser io.Closer + Locker locking.Locker + ApplyLocker locking.ApplyLocker + VCSEventsController *events_controllers.VCSEventsController + GithubAppController *controllers.GithubAppController + LocksController *controllers.LocksController + StatusController *controllers.StatusController + JobsController *controllers.JobsController + APIController *controllers.APIController + IndexTemplate web_templates.TemplateWriter + LockDetailTemplate web_templates.TemplateWriter + ProjectJobsTemplate web_templates.TemplateWriter + ProjectJobsErrorTemplate web_templates.TemplateWriter + SSLCertFile string + SSLKeyFile string + CertLastRefreshTime time.Time + KeyLastRefreshTime time.Time + SSLCert *tls.Certificate + Drainer *events.Drainer + WebAuthentication bool + WebUsername string + WebPassword string + ProjectCmdOutputHandler jobs.ProjectCommandOutputHandler + ScheduledExecutorService *scheduled.ExecutorService + DisableGlobalApplyLock bool +} + +// Config holds config for server that isn't passed in by the user. +type Config struct { + AllowForkPRsFlag string + AtlantisURLFlag string + AtlantisVersion string + DefaultTFVersionFlag string + RepoConfigJSONFlag string + SilenceForkPRErrorsFlag string +} + +// WebhookConfig is nested within UserConfig. It's used to configure webhooks. +type WebhookConfig struct { + // Event is the type of event we should send this webhook for, ex. apply. + Event string `mapstructure:"event"` + // WorkspaceRegex is a regex that is used to match against the workspace + // that is being modified for this event. If the regex matches, we'll + // send the webhook, ex. "production.*". + WorkspaceRegex string `mapstructure:"workspace-regex"` + // BranchRegex is a regex that is used to match against the base branch + // that is being modified for this event. If the regex matches, we'll + // send the webhook, ex. "main.*". + BranchRegex string `mapstructure:"branch-regex"` + // Kind is the type of webhook we should send, ex. slack. + Kind string `mapstructure:"kind"` + // Channel is the channel to send this webhook to. It only applies to + // slack webhooks. Should be without '#'. + Channel string `mapstructure:"channel"` +} + +//go:embed static +var staticAssets embed.FS + +// NewServer returns a new server. If there are issues starting the server or +// its dependencies an error will be returned. This is like the main() function +// for the server CLI command because it injects all the dependencies. +func NewServer(userConfig UserConfig, config Config) (*Server, error) { + logging.SuppressDefaultLogging() + logger, err := logging.NewStructuredLoggerFromLevel(userConfig.ToLogLevel()) + + if err != nil { + return nil, err + } + + var supportedVCSHosts []models.VCSHostType + var githubClient vcs.IGithubClient + var githubAppEnabled bool + var githubConfig vcs.GithubConfig + var githubCredentials vcs.GithubCredentials + var gitlabClient *vcs.GitlabClient + var bitbucketCloudClient *bitbucketcloud.Client + var bitbucketServerClient *bitbucketserver.Client + var azuredevopsClient *vcs.AzureDevopsClient + var giteaClient *gitea.GiteaClient + + policyChecksEnabled := false + if userConfig.EnablePolicyChecksFlag { + logger.Info("Policy Checks are enabled") + policyChecksEnabled = true + } + + allowCommands, err := userConfig.ToAllowCommandNames() + if err != nil { + return nil, err + } + disableApply := true + for _, allowCommand := range allowCommands { + if allowCommand == command.Apply { + disableApply = false + break + } + } + + validator := &cfg.ParserValidator{} + + globalCfg := valid.NewGlobalCfgFromArgs( + valid.GlobalCfgArgs{ + PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag, + }) + if userConfig.RepoConfig != "" { + globalCfg, err = validator.ParseGlobalCfg(userConfig.RepoConfig, globalCfg) + if err != nil { + return nil, errors.Wrapf(err, "parsing %s file", userConfig.RepoConfig) + } + } else if userConfig.RepoConfigJSON != "" { + globalCfg, err = validator.ParseGlobalCfgJSON(userConfig.RepoConfigJSON, globalCfg) + if err != nil { + return nil, errors.Wrapf(err, "parsing --%s", config.RepoConfigJSONFlag) + } + } + + statsScope, statsReporter, closer, err := metrics.NewScope(globalCfg.Metrics, logger, userConfig.StatsNamespace) + + if err != nil { + return nil, errors.Wrapf(err, "instantiating metrics scope") + } + + if userConfig.GithubUser != "" || userConfig.GithubAppID != 0 { + if userConfig.GithubAllowMergeableBypassApply { + githubConfig = vcs.GithubConfig{ + AllowMergeableBypassApply: true, + } + } + supportedVCSHosts = append(supportedVCSHosts, models.Github) + if userConfig.GithubUser != "" { + githubCredentials = &vcs.GithubUserCredentials{ + User: userConfig.GithubUser, + Token: userConfig.GithubToken, + TokenFile: userConfig.GithubTokenFile, + } + } else if userConfig.GithubAppID != 0 && userConfig.GithubAppKeyFile != "" { + privateKey, err := os.ReadFile(userConfig.GithubAppKeyFile) + if err != nil { + return nil, err + } + githubCredentials = &vcs.GithubAppCredentials{ + AppID: userConfig.GithubAppID, + InstallationID: userConfig.GithubAppInstallationID, + Key: privateKey, + Hostname: userConfig.GithubHostname, + AppSlug: userConfig.GithubAppSlug, + } + githubAppEnabled = true + } else if userConfig.GithubAppID != 0 && userConfig.GithubAppKey != "" { + githubCredentials = &vcs.GithubAppCredentials{ + AppID: userConfig.GithubAppID, + InstallationID: userConfig.GithubAppInstallationID, + Key: []byte(userConfig.GithubAppKey), + Hostname: userConfig.GithubHostname, + AppSlug: userConfig.GithubAppSlug, + } + githubAppEnabled = true + } + + var err error + rawGithubClient, err := vcs.NewGithubClient(userConfig.GithubHostname, githubCredentials, githubConfig, userConfig.MaxCommentsPerCommand, logger) + if err != nil { + return nil, err + } + + githubClient = vcs.NewInstrumentedGithubClient(rawGithubClient, statsScope, logger) + } + if userConfig.GitlabUser != "" { + supportedVCSHosts = append(supportedVCSHosts, models.Gitlab) + var err error + gitlabClient, err = vcs.NewGitlabClient(userConfig.GitlabHostname, userConfig.GitlabToken, logger) + if err != nil { + return nil, err + } + } + if userConfig.BitbucketUser != "" { + if userConfig.BitbucketBaseURL == bitbucketcloud.BaseURL { + supportedVCSHosts = append(supportedVCSHosts, models.BitbucketCloud) + bitbucketCloudClient = bitbucketcloud.NewClient( + http.DefaultClient, + userConfig.BitbucketUser, + userConfig.BitbucketToken, + userConfig.AtlantisURL) + } else { + supportedVCSHosts = append(supportedVCSHosts, models.BitbucketServer) + var err error + bitbucketServerClient, err = bitbucketserver.NewClient( + http.DefaultClient, + userConfig.BitbucketUser, + userConfig.BitbucketToken, + userConfig.BitbucketBaseURL, + userConfig.AtlantisURL) + if err != nil { + return nil, errors.Wrapf(err, "setting up Bitbucket Server client") + } + } + } + if userConfig.AzureDevopsUser != "" { + supportedVCSHosts = append(supportedVCSHosts, models.AzureDevops) + + var err error + azuredevopsClient, err = vcs.NewAzureDevopsClient(userConfig.AzureDevOpsHostname, userConfig.AzureDevopsUser, userConfig.AzureDevopsToken) + if err != nil { + return nil, err + } + } + if userConfig.GiteaToken != "" { + supportedVCSHosts = append(supportedVCSHosts, models.Gitea) + + giteaClient, err = gitea.NewClient(userConfig.GiteaBaseURL, userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaPageSize, logger) + if err != nil { + fmt.Println("error setting up gitea client", "error", err) + return nil, errors.Wrapf(err, "setting up Gitea client") + } else { + logger.Info("gitea client configured successfully") + } + } + + var supportedVCSHostsStr []string + for _, host := range supportedVCSHosts { + supportedVCSHostsStr = append(supportedVCSHostsStr, host.String()) + } + + logger.Info("Supported VCS Hosts: %s", strings.Join(supportedVCSHostsStr, ", ")) + + home, err := homedir.Dir() + if err != nil { + return nil, errors.Wrap(err, "getting home dir to write ~/.git-credentials file") + } + + if userConfig.WriteGitCreds { + if userConfig.GithubUser != "" { + if err := vcs.WriteGitCreds(userConfig.GithubUser, userConfig.GithubToken, userConfig.GithubHostname, home, logger, false); err != nil { + return nil, err + } + } + if userConfig.GitlabUser != "" { + if err := vcs.WriteGitCreds(userConfig.GitlabUser, userConfig.GitlabToken, userConfig.GitlabHostname, home, logger, false); err != nil { + return nil, err + } + } + if userConfig.BitbucketUser != "" { + // The default BitbucketBaseURL is https://api.bitbucket.org which can't actually be used for git + // so we override it here only if it's that to be bitbucket.org + bitbucketBaseURL := userConfig.BitbucketBaseURL + if bitbucketBaseURL == "https://api.bitbucket.org" { + bitbucketBaseURL = "bitbucket.org" + } + if err := vcs.WriteGitCreds(userConfig.BitbucketUser, userConfig.BitbucketToken, bitbucketBaseURL, home, logger, false); err != nil { + return nil, err + } + } + if userConfig.AzureDevopsUser != "" { + if err := vcs.WriteGitCreds(userConfig.AzureDevopsUser, userConfig.AzureDevopsToken, "dev.azure.com", home, logger, false); err != nil { + return nil, err + } + } + if userConfig.GiteaUser != "" { + if err := vcs.WriteGitCreds(userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaBaseURL, home, logger, false); err != nil { + return nil, err + } + } + } + + // default the project files used to generate the module index to the autoplan-file-list if autoplan-modules is true + // but no files are specified + if userConfig.AutoplanModules && userConfig.AutoplanModulesFromProjects == "" { + userConfig.AutoplanModulesFromProjects = userConfig.AutoplanFileList + } + + var webhooksConfig []webhooks.Config + for _, c := range userConfig.Webhooks { + config := webhooks.Config{ + Channel: c.Channel, + BranchRegex: c.BranchRegex, + Event: c.Event, + Kind: c.Kind, + WorkspaceRegex: c.WorkspaceRegex, + } + webhooksConfig = append(webhooksConfig, config) + } + webhooksManager, err := webhooks.NewMultiWebhookSender(webhooksConfig, webhooks.NewSlackClient(userConfig.SlackToken)) + if err != nil { + return nil, errors.Wrap(err, "initializing webhooks") + } + vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient, giteaClient) + commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient, StatusName: userConfig.VCSStatusName} + + binDir, err := mkSubDir(userConfig.DataDir, BinDirName) + + if err != nil { + return nil, err + } + + cacheDir, err := mkSubDir(userConfig.DataDir, TerraformPluginCacheDirName) + + if err != nil { + return nil, err + } + + parsedURL, err := ParseAtlantisURL(userConfig.AtlantisURL) + if err != nil { + return nil, errors.Wrapf(err, + "parsing --%s flag %q", config.AtlantisURLFlag, userConfig.AtlantisURL) + } + + underlyingRouter := mux.NewRouter() + router := &Router{ + AtlantisURL: parsedURL, + LockViewRouteIDQueryParam: LockViewRouteIDQueryParam, + LockViewRouteName: LockViewRouteName, + ProjectJobsViewRouteName: ProjectJobsViewRouteName, + Underlying: underlyingRouter, + } + + var projectCmdOutputHandler jobs.ProjectCommandOutputHandler + + if userConfig.TFEToken != "" && !userConfig.TFELocalExecutionMode { + // When TFE is enabled and using remote execution mode log streaming is not necessary. + projectCmdOutputHandler = &jobs.NoopProjectOutputHandler{} + } else { + projectCmdOutput := make(chan *jobs.ProjectCmdOutputLine) + projectCmdOutputHandler = jobs.NewAsyncProjectCommandOutputHandler( + projectCmdOutput, + logger, + ) + } + + distribution := terraform.NewDistributionTerraform() + if userConfig.TFDistribution == "opentofu" { + distribution = terraform.NewDistributionOpenTofu() + } + + terraformClient, err := terraform.NewClient( + logger, + distribution, + binDir, + cacheDir, + userConfig.TFEToken, + userConfig.TFEHostname, + userConfig.DefaultTFVersion, + config.DefaultTFVersionFlag, + userConfig.TFDownloadURL, + userConfig.TFDownload, + userConfig.UseTFPluginCache, + projectCmdOutputHandler) + // The flag.Lookup call is to detect if we're running in a unit test. If we + // are, then we don't error out because we don't have/want terraform + // installed on our CI system where the unit tests run. + if err != nil && flag.Lookup("test.v") == nil { + return nil, errors.Wrap(err, fmt.Sprintf("initializing %s", userConfig.TFDistribution)) + } + markdownRenderer := events.NewMarkdownRenderer( + gitlabClient.SupportsCommonMark(), + userConfig.DisableApplyAll, + disableApply, + userConfig.DisableMarkdownFolding, + userConfig.DisableRepoLocking, + userConfig.EnableDiffMarkdownFormat, + userConfig.MarkdownTemplateOverridesDir, + userConfig.ExecutableName, + userConfig.HideUnchangedPlanComments, + ) + + var lockingClient locking.Locker + var applyLockingClient locking.ApplyLocker + var backend locking.Backend + + switch dbtype := userConfig.LockingDBType; dbtype { + case "redis": + logger.Info("Utilizing Redis DB") + backend, err = redis.New(userConfig.RedisHost, userConfig.RedisPort, userConfig.RedisPassword, userConfig.RedisTLSEnabled, userConfig.RedisInsecureSkipVerify, userConfig.RedisDB) + if err != nil { + return nil, err + } + case "boltdb": + logger.Info("Utilizing BoltDB") + backend, err = db.New(userConfig.DataDir) + if err != nil { + return nil, err + } + } + + noOpLocker := locking.NewNoOpLocker() + if userConfig.DisableRepoLocking { + logger.Info("Repo Locking is disabled") + lockingClient = noOpLocker + } else { + lockingClient = locking.NewClient(backend) + } + disableGlobalApplyLock := false + if userConfig.DisableGlobalApplyLock { + disableGlobalApplyLock = true + } + + applyLockingClient = locking.NewApplyClient(backend, disableApply, disableGlobalApplyLock) + workingDirLocker := events.NewDefaultWorkingDirLocker() + + var workingDir events.WorkingDir = &events.FileWorkspace{ + DataDir: userConfig.DataDir, + CheckoutMerge: userConfig.CheckoutStrategy == "merge", + CheckoutDepth: userConfig.CheckoutDepth, + GithubAppEnabled: githubAppEnabled, + } + + scheduledExecutorService := scheduled.NewExecutorService( + statsScope, + logger, + ) + + // provide fresh tokens before clone from the GitHub Apps integration, proxy workingDir + if githubAppEnabled { + if !userConfig.WriteGitCreds { + return nil, errors.New("Github App requires --write-git-creds to support cloning") + } + workingDir = &events.GithubAppWorkingDir{ + WorkingDir: workingDir, + Credentials: githubCredentials, + GithubHostname: userConfig.GithubHostname, + } + + githubAppTokenRotator := vcs.NewGithubTokenRotator(logger, githubCredentials, userConfig.GithubHostname, "x-access-token", home) + tokenJd, err := githubAppTokenRotator.GenerateJob() + if err != nil { + return nil, errors.Wrap(err, "could not write credentials") + } + scheduledExecutorService.AddJob(tokenJd) + } + + if userConfig.GithubUser != "" && userConfig.GithubTokenFile != "" && userConfig.WriteGitCreds { + githubTokenRotator := vcs.NewGithubTokenRotator(logger, githubCredentials, userConfig.GithubHostname, userConfig.GithubUser, home) + tokenJd, err := githubTokenRotator.GenerateJob() + if err != nil { + return nil, errors.Wrap(err, "could not write credentials") + } + scheduledExecutorService.AddJob(tokenJd) + } + + projectLocker := &events.DefaultProjectLocker{ + Locker: lockingClient, + NoOpLocker: noOpLocker, + VCSClient: vcsClient, + } + deleteLockCommand := &events.DefaultDeleteLockCommand{ + Locker: lockingClient, + WorkingDir: workingDir, + WorkingDirLocker: workingDirLocker, + Backend: backend, + } + + pullClosedExecutor := events.NewInstrumentedPullClosedExecutor( + statsScope, + logger, + &events.PullClosedExecutor{ + Locker: lockingClient, + WorkingDir: workingDir, + Backend: backend, + PullClosedTemplate: &events.PullClosedEventTemplate{}, + LogStreamResourceCleaner: projectCmdOutputHandler, + VCSClient: vcsClient, + }, + ) + + eventParser := &events.EventParser{ + GithubUser: userConfig.GithubUser, + GithubToken: userConfig.GithubToken, + GithubTokenFile: userConfig.GithubTokenFile, + GitlabUser: userConfig.GitlabUser, + GitlabToken: userConfig.GitlabToken, + GiteaUser: userConfig.GiteaUser, + GiteaToken: userConfig.GiteaToken, + AllowDraftPRs: userConfig.PlanDrafts, + BitbucketUser: userConfig.BitbucketUser, + BitbucketToken: userConfig.BitbucketToken, + BitbucketServerURL: userConfig.BitbucketBaseURL, + AzureDevopsUser: userConfig.AzureDevopsUser, + AzureDevopsToken: userConfig.AzureDevopsToken, + } + commentParser := events.NewCommentParser( + userConfig.GithubUser, + userConfig.GitlabUser, + userConfig.GiteaUser, + userConfig.BitbucketUser, + userConfig.AzureDevopsUser, + userConfig.ExecutableName, + allowCommands, + ) + defaultTfVersion := terraformClient.DefaultVersion() + pendingPlanFinder := &events.DefaultPendingPlanFinder{} + runStepRunner := &runtime.RunStepRunner{ + TerraformExecutor: terraformClient, + DefaultTFVersion: defaultTfVersion, + TerraformBinDir: terraformClient.TerraformBinDir(), + ProjectCmdOutputHandler: projectCmdOutputHandler, + } + drainer := &events.Drainer{} + statusController := &controllers.StatusController{ + Logger: logger, + Drainer: drainer, + AtlantisVersion: config.AtlantisVersion, + } + preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{ + VCSClient: vcsClient, + GlobalCfg: globalCfg, + WorkingDirLocker: workingDirLocker, + WorkingDir: workingDir, + PreWorkflowHookRunner: runtime.DefaultPreWorkflowHookRunner{ + OutputHandler: projectCmdOutputHandler, + }, + CommitStatusUpdater: commitStatusUpdater, + Router: router, + } + postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{ + VCSClient: vcsClient, + GlobalCfg: globalCfg, + WorkingDirLocker: workingDirLocker, + WorkingDir: workingDir, + PostWorkflowHookRunner: runtime.DefaultPostWorkflowHookRunner{ + OutputHandler: projectCmdOutputHandler, + }, + CommitStatusUpdater: commitStatusUpdater, + Router: router, + } + projectCommandBuilder := events.NewInstrumentedProjectCommandBuilder( + logger, + policyChecksEnabled, + validator, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + workingDirLocker, + globalCfg, + pendingPlanFinder, + commentParser, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.Automerge, + userConfig.ParallelPlan, + userConfig.ParallelApply, + userConfig.AutoplanModulesFromProjects, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverModeFlag, + statsScope, + terraformClient, + ) + + showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfVersion) + + if err != nil { + return nil, errors.Wrap(err, "initializing show step runner") + } + + policyCheckStepRunner, err := runtime.NewPolicyCheckStepRunner( + defaultTfVersion, + policy.NewConfTestExecutorWorkflow(logger, binDir, &policy.ConfTestGoGetterVersionDownloader{}), + ) + + if err != nil { + return nil, errors.Wrap(err, "initializing policy check step runner") + } + + applyRequirementHandler := &events.DefaultCommandRequirementHandler{ + WorkingDir: workingDir, + } + + projectCommandRunner := &events.DefaultProjectCommandRunner{ + VcsClient: vcsClient, + Locker: projectLocker, + LockURLGenerator: router, + InitStepRunner: &runtime.InitStepRunner{ + TerraformExecutor: terraformClient, + DefaultTFVersion: defaultTfVersion, + }, + PlanStepRunner: runtime.NewPlanStepRunner(terraformClient, defaultTfVersion, commitStatusUpdater, terraformClient), + ShowStepRunner: showStepRunner, + PolicyCheckStepRunner: policyCheckStepRunner, + ApplyStepRunner: &runtime.ApplyStepRunner{ + TerraformExecutor: terraformClient, + DefaultTFVersion: defaultTfVersion, + CommitStatusUpdater: commitStatusUpdater, + AsyncTFExec: terraformClient, + }, + RunStepRunner: runStepRunner, + EnvStepRunner: &runtime.EnvStepRunner{ + RunStepRunner: runStepRunner, + }, + MultiEnvStepRunner: &runtime.MultiEnvStepRunner{ + RunStepRunner: runStepRunner, + }, + VersionStepRunner: &runtime.VersionStepRunner{ + TerraformExecutor: terraformClient, + DefaultTFVersion: defaultTfVersion, + }, + ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTfVersion), + StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTfVersion), + WorkingDir: workingDir, + Webhooks: webhooksManager, + WorkingDirLocker: workingDirLocker, + CommandRequirementHandler: applyRequirementHandler, + } + + dbUpdater := &events.DBUpdater{ + Backend: backend, + } + + pullUpdater := &events.PullUpdater{ + HidePrevPlanComments: userConfig.HidePrevPlanComments, + VCSClient: vcsClient, + MarkdownRenderer: markdownRenderer, + } + + autoMerger := &events.AutoMerger{ + VCSClient: vcsClient, + GlobalAutomerge: userConfig.Automerge, + } + + projectOutputWrapper := &events.ProjectOutputWrapper{ + JobMessageSender: projectCmdOutputHandler, + ProjectCommandRunner: projectCommandRunner, + JobURLSetter: jobs.NewJobURLSetter(router, commitStatusUpdater), + } + instrumentedProjectCmdRunner := events.NewInstrumentedProjectCommandRunner( + statsScope, + projectOutputWrapper, + ) + + policyCheckCommandRunner := events.NewPolicyCheckCommandRunner( + dbUpdater, + pullUpdater, + commitStatusUpdater, + instrumentedProjectCmdRunner, + userConfig.ParallelPoolSize, + userConfig.SilenceVCSStatusNoProjects, + userConfig.QuietPolicyChecks, + ) + + pullReqStatusFetcher := vcs.NewPullReqStatusFetcher(vcsClient, userConfig.VCSStatusName, strings.Split(userConfig.IgnoreVCSStatusNames, ",")) + planCommandRunner := events.NewPlanCommandRunner( + userConfig.SilenceVCSStatusNoPlans, + userConfig.SilenceVCSStatusNoProjects, + vcsClient, + pendingPlanFinder, + workingDir, + commitStatusUpdater, + projectCommandBuilder, + instrumentedProjectCmdRunner, + dbUpdater, + pullUpdater, + policyCheckCommandRunner, + autoMerger, + userConfig.ParallelPoolSize, + userConfig.SilenceNoProjects, + backend, + lockingClient, + userConfig.DiscardApprovalOnPlanFlag, + pullReqStatusFetcher, + ) + + applyCommandRunner := events.NewApplyCommandRunner( + vcsClient, + userConfig.DisableApplyAll, + applyLockingClient, + commitStatusUpdater, + projectCommandBuilder, + instrumentedProjectCmdRunner, + autoMerger, + pullUpdater, + dbUpdater, + backend, + userConfig.ParallelPoolSize, + userConfig.SilenceNoProjects, + userConfig.SilenceVCSStatusNoProjects, + pullReqStatusFetcher, + ) + + approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner( + commitStatusUpdater, + projectCommandBuilder, + instrumentedProjectCmdRunner, + pullUpdater, + dbUpdater, + userConfig.SilenceNoProjects, + userConfig.SilenceVCSStatusNoPlans, + vcsClient, + ) + + unlockCommandRunner := events.NewUnlockCommandRunner( + deleteLockCommand, + vcsClient, + userConfig.SilenceNoProjects, + userConfig.DisableUnlockLabel, + ) + + versionCommandRunner := events.NewVersionCommandRunner( + pullUpdater, + projectCommandBuilder, + projectOutputWrapper, + userConfig.ParallelPoolSize, + userConfig.SilenceNoProjects, + ) + + importCommandRunner := events.NewImportCommandRunner( + pullUpdater, + pullReqStatusFetcher, + projectCommandBuilder, + instrumentedProjectCmdRunner, + userConfig.SilenceNoProjects, + ) + + stateCommandRunner := events.NewStateCommandRunner( + pullUpdater, + projectCommandBuilder, + instrumentedProjectCmdRunner, + ) + + commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ + command.Plan: planCommandRunner, + command.Apply: applyCommandRunner, + command.ApprovePolicies: approvePoliciesCommandRunner, + command.Unlock: unlockCommandRunner, + command.Version: versionCommandRunner, + command.Import: importCommandRunner, + command.State: stateCommandRunner, + } + + var teamAllowlistChecker command.TeamAllowlistChecker + if globalCfg.TeamAuthz.Command != "" { + teamAllowlistChecker = &events.ExternalTeamAllowlistChecker{ + Command: globalCfg.TeamAuthz.Command, + ExtraArgs: globalCfg.TeamAuthz.Args, + ExternalTeamAllowlistRunner: &runtime.DefaultExternalTeamAllowlistRunner{}, + } + } else { + teamAllowlistChecker, err = command.NewTeamAllowlistChecker(userConfig.GithubTeamAllowlist) + if err != nil { + return nil, err + } + } + + varFileAllowlistChecker, err := events.NewVarFileAllowlistChecker(userConfig.VarFileAllowlist) + if err != nil { + return nil, err + } + + commandRunner := &events.DefaultCommandRunner{ + VCSClient: vcsClient, + GithubPullGetter: githubClient, + GitlabMergeRequestGetter: gitlabClient, + AzureDevopsPullGetter: azuredevopsClient, + GiteaPullGetter: giteaClient, + CommentCommandRunnerByCmd: commentCommandRunnerByCmd, + EventParser: eventParser, + FailOnPreWorkflowHookError: userConfig.FailOnPreWorkflowHookError, + Logger: logger, + GlobalCfg: globalCfg, + StatsScope: statsScope.SubScope("cmd"), + AllowForkPRs: userConfig.AllowForkPRs, + AllowForkPRsFlag: config.AllowForkPRsFlag, + SilenceForkPRErrors: userConfig.SilenceForkPRErrors, + SilenceForkPRErrorsFlag: config.SilenceForkPRErrorsFlag, + DisableAutoplan: userConfig.DisableAutoplan, + DisableAutoplanLabel: userConfig.DisableAutoplanLabel, + Drainer: drainer, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + PullStatusFetcher: backend, + TeamAllowlistChecker: teamAllowlistChecker, + VarFileAllowlistChecker: varFileAllowlistChecker, + CommitStatusUpdater: commitStatusUpdater, + ApplyLocker: applyLockingClient, + } + repoAllowlist, err := events.NewRepoAllowlistChecker(userConfig.RepoAllowlist) + if err != nil { + return nil, err + } + locksController := &controllers.LocksController{ + AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, + Locker: lockingClient, + ApplyLocker: applyLockingClient, + Logger: logger, + VCSClient: vcsClient, + LockDetailTemplate: web_templates.LockTemplate, + WorkingDir: workingDir, + WorkingDirLocker: workingDirLocker, + Backend: backend, + DeleteLockCommand: deleteLockCommand, + } + + wsMux := websocket.NewMultiplexor( + logger, + controllers.JobIDKeyGenerator{}, + projectCmdOutputHandler, + userConfig.WebsocketCheckOrigin, + ) + + jobsController := &controllers.JobsController{ + AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, + Logger: logger, + ProjectJobsTemplate: web_templates.ProjectJobsTemplate, + ProjectJobsErrorTemplate: web_templates.ProjectJobsErrorTemplate, + Backend: backend, + WsMux: wsMux, + KeyGenerator: controllers.JobIDKeyGenerator{}, + StatsScope: statsScope.SubScope("api"), + } + apiController := &controllers.APIController{ + APISecret: []byte(userConfig.APISecret), + Locker: lockingClient, + Logger: logger, + Parser: eventParser, + ProjectCommandBuilder: projectCommandBuilder, + ProjectPlanCommandRunner: instrumentedProjectCmdRunner, + ProjectApplyCommandRunner: instrumentedProjectCmdRunner, + FailOnPreWorkflowHookError: userConfig.FailOnPreWorkflowHookError, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + RepoAllowlistChecker: repoAllowlist, + Scope: statsScope.SubScope("api"), + VCSClient: vcsClient, + } + + eventsController := &events_controllers.VCSEventsController{ + CommandRunner: commandRunner, + PullCleaner: pullClosedExecutor, + Parser: eventParser, + CommentParser: commentParser, + Logger: logger, + Scope: statsScope, + ApplyDisabled: disableApply, + GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), + GithubRequestValidator: &events_controllers.DefaultGithubRequestValidator{}, + GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{}, + GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret), + RepoAllowlistChecker: repoAllowlist, + SilenceAllowlistErrors: userConfig.SilenceAllowlistErrors, + EmojiReaction: userConfig.EmojiReaction, + ExecutableName: userConfig.ExecutableName, + SupportedVCSHosts: supportedVCSHosts, + VCSClient: vcsClient, + BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), + AzureDevopsWebhookBasicUser: []byte(userConfig.AzureDevopsWebhookUser), + AzureDevopsWebhookBasicPassword: []byte(userConfig.AzureDevopsWebhookPassword), + AzureDevopsRequestValidator: &events_controllers.DefaultAzureDevopsRequestValidator{}, + GiteaWebhookSecret: []byte(userConfig.GiteaWebhookSecret), + } + githubAppController := &controllers.GithubAppController{ + AtlantisURL: parsedURL, + Logger: logger, + GithubSetupComplete: githubAppEnabled, + GithubHostname: userConfig.GithubHostname, + GithubOrg: userConfig.GithubOrg, + } + + return &Server{ + AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, + Router: underlyingRouter, + Port: userConfig.Port, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + CommandRunner: commandRunner, + Logger: logger, + StatsScope: statsScope, + StatsReporter: statsReporter, + StatsCloser: closer, + Locker: lockingClient, + ApplyLocker: applyLockingClient, + VCSEventsController: eventsController, + GithubAppController: githubAppController, + LocksController: locksController, + JobsController: jobsController, + StatusController: statusController, + APIController: apiController, + IndexTemplate: web_templates.IndexTemplate, + LockDetailTemplate: web_templates.LockTemplate, + ProjectJobsTemplate: web_templates.ProjectJobsTemplate, + ProjectJobsErrorTemplate: web_templates.ProjectJobsErrorTemplate, + SSLKeyFile: userConfig.SSLKeyFile, + SSLCertFile: userConfig.SSLCertFile, + DisableGlobalApplyLock: userConfig.DisableGlobalApplyLock, + Drainer: drainer, + ProjectCmdOutputHandler: projectCmdOutputHandler, + WebAuthentication: userConfig.WebBasicAuth, + WebUsername: userConfig.WebUsername, + WebPassword: userConfig.WebPassword, + ScheduledExecutorService: scheduledExecutorService, + }, nil +} + +// Start creates the routes and starts serving traffic. +func (s *Server) Start() error { + s.Router.HandleFunc("/", s.Index).Methods("GET").MatcherFunc(func(r *http.Request, rm *mux.RouteMatch) bool { + return r.URL.Path == "/" || r.URL.Path == "/index.html" + }) + s.Router.HandleFunc("/healthz", s.Healthz).Methods("GET") + s.Router.HandleFunc("/status", s.StatusController.Get).Methods("GET") + s.Router.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticAssets))) + s.Router.HandleFunc("/events", s.VCSEventsController.Post).Methods("POST") + s.Router.HandleFunc("/api/plan", s.APIController.Plan).Methods("POST") + s.Router.HandleFunc("/api/apply", s.APIController.Apply).Methods("POST") + s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET") + s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET") + s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}") + s.Router.HandleFunc("/lock", s.LocksController.GetLock).Methods("GET"). + Queries(LockViewRouteIDQueryParam, fmt.Sprintf("{%s}", LockViewRouteIDQueryParam)).Name(LockViewRouteName) + s.Router.HandleFunc("/jobs/{job-id}", s.JobsController.GetProjectJobs).Methods("GET").Name(ProjectJobsViewRouteName) + s.Router.HandleFunc("/jobs/{job-id}/ws", s.JobsController.GetProjectJobsWS).Methods("GET") + + r, ok := s.StatsReporter.(prometheus.Reporter) + if ok { + s.Router.Handle(s.CommandRunner.GlobalCfg.Metrics.Prometheus.Endpoint, r.HTTPHandler()) + } + if !s.DisableGlobalApplyLock { + s.Router.HandleFunc("/apply/lock", s.LocksController.LockApply).Methods("POST").Queries() + s.Router.HandleFunc("/apply/unlock", s.LocksController.UnlockApply).Methods("DELETE").Queries() + } + + n := negroni.New(&negroni.Recovery{ + Logger: log.New(os.Stdout, "", log.LstdFlags), + PrintStack: false, + StackAll: false, + StackSize: 1024 * 8, + }, NewRequestLogger(s)) + n.UseHandler(s.Router) + + defer s.Logger.Flush() + + // Ensure server gracefully drains connections when stopped. + stop := make(chan os.Signal, 1) + // Stop on SIGINTs and SIGTERMs. + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + go s.ScheduledExecutorService.Run() + + go func() { + s.ProjectCmdOutputHandler.Handle() + }() + + tlsConfig := &tls.Config{GetCertificate: s.GetSSLCertificate, MinVersion: tls.VersionTLS12} + + server := &http.Server{Addr: fmt.Sprintf(":%d", s.Port), Handler: n, TLSConfig: tlsConfig, ReadHeaderTimeout: 10 * time.Second} + go func() { + s.Logger.Info("Atlantis started - listening on port %v", s.Port) + + var err error + if s.SSLCertFile != "" && s.SSLKeyFile != "" { + err = server.ListenAndServeTLS("", "") + } else { + err = server.ListenAndServe() + } + + if err != nil && err != http.ErrServerClosed { + s.Logger.Err(err.Error()) + } + }() + <-stop + + s.Logger.Warn("Received interrupt. Waiting for in-progress operations to complete") + s.waitForDrain() + + // flush stats before shutdown + if err := s.StatsCloser.Close(); err != nil { + s.Logger.Err(err.Error()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + return fmt.Errorf("while shutting down: %s", err) + } + return nil +} + +// waitForDrain blocks until draining is complete. +func (s *Server) waitForDrain() { + drainComplete := make(chan bool, 1) + go func() { + s.Drainer.ShutdownBlocking() + drainComplete <- true + }() + ticker := time.NewTicker(5 * time.Second) + for { + select { + case <-drainComplete: + s.Logger.Info("All in-progress operations complete, shutting down") + return + case <-ticker.C: + s.Logger.Info("Waiting for in-progress operations to complete, current in-progress ops: %d", s.Drainer.GetStatus().InProgressOps) + } + } +} + +// Index is the / route. +func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { + locks, err := s.Locker.List() + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "Could not retrieve locks: %s", err) + return + } + + var lockResults []web_templates.LockIndexData + for id, v := range locks { + lockURL, _ := s.Router.Get(LockViewRouteName).URL("id", url.QueryEscape(id)) + lockResults = append(lockResults, web_templates.LockIndexData{ + // NOTE: must use .String() instead of .Path because we need the + // query params as part of the lock URL. + LockPath: lockURL.String(), + RepoFullName: v.Project.RepoFullName, + LockedBy: v.Pull.Author, + PullNum: v.Pull.Num, + Path: v.Project.Path, + Workspace: v.Workspace, + Time: v.Time, + TimeFormatted: v.Time.Format("2006-01-02 15:04:05"), + }) + } + + applyCmdLock, err := s.ApplyLocker.CheckApplyLock() + s.Logger.Debug("Apply Lock: %v", applyCmdLock) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "Could not retrieve global apply lock: %s", err) + return + } + + applyLockData := web_templates.ApplyLockData{ + Time: applyCmdLock.Time, + Locked: applyCmdLock.Locked, + GlobalApplyLockEnabled: applyCmdLock.GlobalApplyLockEnabled, + TimeFormatted: applyCmdLock.Time.Format("2006-01-02 15:04:05"), + } + //Sort by date - newest to oldest. + sort.SliceStable(lockResults, func(i, j int) bool { return lockResults[i].Time.After(lockResults[j].Time) }) + + err = s.IndexTemplate.Execute(w, web_templates.IndexData{ + Locks: lockResults, + PullToJobMapping: preparePullToJobMappings(s), + ApplyLock: applyLockData, + AtlantisVersion: s.AtlantisVersion, + CleanedBasePath: s.AtlantisURL.Path, + }) + if err != nil { + s.Logger.Err(err.Error()) + } +} + +func preparePullToJobMappings(s *Server) []jobs.PullInfoWithJobIDs { + + pullToJobMappings := s.ProjectCmdOutputHandler.GetPullToJobMapping() + + for i := range pullToJobMappings { + for j := range pullToJobMappings[i].JobIDInfos { + jobUrl, _ := s.Router.Get(ProjectJobsViewRouteName).URL("job-id", pullToJobMappings[i].JobIDInfos[j].JobID) + pullToJobMappings[i].JobIDInfos[j].JobIDUrl = jobUrl.String() + pullToJobMappings[i].JobIDInfos[j].TimeFormatted = pullToJobMappings[i].JobIDInfos[j].Time.Format("2006-01-02 15:04:05") + } + + //Sort by date - newest to oldest. + sort.SliceStable(pullToJobMappings[i].JobIDInfos, func(x, y int) bool { + return pullToJobMappings[i].JobIDInfos[x].Time.After(pullToJobMappings[i].JobIDInfos[y].Time) + }) + } + + //Sort by repository, project, path, workspace then date. + sort.SliceStable(pullToJobMappings, func(x, y int) bool { + if pullToJobMappings[x].Pull.RepoFullName != pullToJobMappings[y].Pull.RepoFullName { + return pullToJobMappings[x].Pull.RepoFullName < pullToJobMappings[y].Pull.RepoFullName + } + if pullToJobMappings[x].Pull.ProjectName != pullToJobMappings[y].Pull.ProjectName { + return pullToJobMappings[x].Pull.ProjectName < pullToJobMappings[y].Pull.ProjectName + } + if pullToJobMappings[x].Pull.Path != pullToJobMappings[y].Pull.Path { + return pullToJobMappings[x].Pull.Path < pullToJobMappings[y].Pull.Path + } + return pullToJobMappings[x].Pull.Workspace < pullToJobMappings[y].Pull.Workspace + }) + + return pullToJobMappings +} + +func mkSubDir(parentDir string, subDir string) (string, error) { + fullDir := filepath.Join(parentDir, subDir) + if err := os.MkdirAll(fullDir, 0700); err != nil { + return "", errors.Wrapf(err, "unable to create dir %q", fullDir) + } + + return fullDir, nil +} + +// Healthz returns the health check response. It always returns a 200 currently. +func (s *Server) Healthz(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(healthzData) // nolint: errcheck +} + +var healthzData = []byte(`{ + "status": "ok" +}`) + +func (s *Server) GetSSLCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + certStat, err := os.Stat(s.SSLCertFile) + if err != nil { + return nil, fmt.Errorf("while getting cert file modification time: %w", err) + } + + keyStat, err := os.Stat(s.SSLKeyFile) + if err != nil { + return nil, fmt.Errorf("while getting key file modification time: %w", err) + } + + if s.SSLCert == nil || certStat.ModTime() != s.CertLastRefreshTime || keyStat.ModTime() != s.KeyLastRefreshTime { + cert, err := tls.LoadX509KeyPair(s.SSLCertFile, s.SSLKeyFile) + if err != nil { + return nil, fmt.Errorf("while loading tls cert: %w", err) + } + + s.SSLCert = &cert + s.CertLastRefreshTime = certStat.ModTime() + s.KeyLastRefreshTime = keyStat.ModTime() + } + return s.SSLCert, nil +} + +// ParseAtlantisURL parses the user-passed atlantis URL to ensure it is valid +// and we can use it in our templates. +// It removes any trailing slashes from the path so we can concatenate it +// with other paths without checking. +func ParseAtlantisURL(u string) (*url.URL, error) { + parsed, err := url.Parse(u) + if err != nil { + return nil, err + } + if !(parsed.Scheme == "http" || parsed.Scheme == "https") { + return nil, errors.New("http or https must be specified") + } + // We want the path to end without a trailing slash so we know how to + // use it in the rest of the program. + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + return parsed, nil +}