Files
atlantis/server/server.go
Leandro López 375b429749 feat: add HTTP pprof endpoints (#5363)
Signed-off-by: Leandro López (inkel) <leandro.lopez@grafana.com>
2025-05-09 09:20:24 -07:00

1310 lines
46 KiB
Go

// 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/http/pprof"
"net/url"
"os"
"os/signal"
"path/filepath"
"slices"
"sort"
"strings"
"syscall"
"time"
"github.com/go-playground/validator/v10"
"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/core/terraform/tfclient"
"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
EnableProfilingAPI bool
}
// Config holds config for server that isn't passed in by the user.
type Config struct {
AllowForkPRsFlag string
AtlantisURLFlag string
AtlantisVersion string
DefaultTFDistributionFlag 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 or http.
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"`
// URL is the URL where to deliver this webhook. It only applies to
// http webhooks.
URL string `mapstructure:"url"`
}
//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
}
}
parserValidator := &cfg.ParserValidator{}
globalCfg := valid.NewGlobalCfgFromArgs(
valid.GlobalCfgArgs{
PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag,
})
if userConfig.RepoConfig != "" {
globalCfg, err = parserValidator.ParseGlobalCfg(userConfig.RepoConfig, globalCfg)
if err != nil {
return nil, errors.Wrapf(err, "parsing %s file", userConfig.RepoConfig)
}
} else if userConfig.RepoConfigJSON != "" {
globalCfg, err = parserValidator.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
gitlabGroupAllowlistChecker, err := command.NewTeamAllowlistChecker(userConfig.GitlabGroupAllowlist)
if err != nil {
return nil, err
}
gitlabGroups := slices.Concat(gitlabGroupAllowlistChecker.AllTeams(), globalCfg.PolicySets.AllTeams())
slices.Sort(gitlabGroups)
gitlabClient, err = vcs.NewGitlabClient(userConfig.GitlabHostname, userConfig.GitlabToken, slices.Compact(gitlabGroups), 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,
URL: c.URL,
}
webhooksConfig = append(webhooksConfig, config)
}
webhookHeaders, err := userConfig.ToWebhookHttpHeaders()
if err != nil {
return nil, errors.Wrap(err, "parsing webhook http headers")
}
webhooksManager, err := webhooks.NewMultiWebhookSender(
webhooksConfig,
webhooks.Clients{
Slack: webhooks.NewSlackClient(userConfig.SlackToken),
Http: &webhooks.HttpClient{Client: http.DefaultClient, Headers: webhookHeaders},
},
)
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.NewDistribution(userConfig.DefaultTFDistribution)
terraformClient, err := tfclient.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.DefaultTFDistribution))
}
markdownRenderer := events.NewMarkdownRenderer(
gitlabClient.SupportsCommonMark(),
userConfig.DisableApplyAll,
disableApply,
userConfig.DisableMarkdownFolding,
userConfig.DisableRepoLocking,
userConfig.EnableDiffMarkdownFormat,
userConfig.MarkdownTemplateOverridesDir,
userConfig.ExecutableName,
userConfig.HideUnchangedPlanComments,
userConfig.QuietPolicyChecks,
)
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,
)
defaultTfDistribution := terraformClient.DefaultDistribution()
defaultTfVersion := terraformClient.DefaultVersion()
pendingPlanFinder := &events.DefaultPendingPlanFinder{}
runStepRunner := &runtime.RunStepRunner{
TerraformExecutor: terraformClient,
DefaultTFDistribution: defaultTfDistribution,
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,
parserValidator,
&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, defaultTfDistribution, defaultTfVersion)
if err != nil {
return nil, errors.Wrap(err, "initializing show step runner")
}
policyCheckStepRunner, err := runtime.NewPolicyCheckStepRunner(
defaultTfDistribution,
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,
Logger: logger,
InitStepRunner: &runtime.InitStepRunner{
TerraformExecutor: terraformClient,
DefaultTFDistribution: defaultTfDistribution,
DefaultTFVersion: defaultTfVersion,
},
PlanStepRunner: runtime.NewPlanStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion, commitStatusUpdater, terraformClient),
ShowStepRunner: showStepRunner,
PolicyCheckStepRunner: policyCheckStepRunner,
ApplyStepRunner: &runtime.ApplyStepRunner{
TerraformExecutor: terraformClient,
DefaultTFDistribution: defaultTfDistribution,
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, defaultTfDistribution, defaultTfVersion),
StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTfDistribution, 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 if userConfig.GitlabUser != "" {
teamAllowlistChecker, err = command.NewTeamAllowlistChecker(userConfig.GitlabGroupAllowlist)
if err != nil {
return nil, err
}
} 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,
}
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,
WorkingDir: workingDir,
WorkingDirLocker: workingDirLocker,
CommitStatusUpdater: commitStatusUpdater,
}
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,
}
server := &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,
EnableProfilingAPI: userConfig.EnableProfilingAPI,
}
validate := validator.New(validator.WithRequiredStructEnabled())
err = validate.Struct(server)
if err != nil {
return nil, err
} else {
return server, 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("/api/locks", s.APIController.ListLocks).Methods("GET")
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()
}
if s.EnableProfilingAPI {
for p, h := range map[string]http.HandlerFunc{
"/": pprof.Index,
"/cmdline": pprof.Cmdline,
"/profile": pprof.Profile,
"/symbol": pprof.Symbol,
"/trace": pprof.Trace,
} {
s.Router.HandleFunc("/debug/pprof"+p, h).Methods("GET")
}
}
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
}