Files
atlantis/server/server.go
2018-08-30 16:47:17 -06:00

440 lines
16 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"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/elazarl/go-bindata-assetfs"
"github.com/gorilla/mux"
"github.com/lkysow/go-gitlab"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/locking/boltdb"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/runtime"
"github.com/runatlantis/atlantis/server/events/terraform"
"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/webhooks"
"github.com/runatlantis/atlantis/server/events/yaml"
"github.com/runatlantis/atlantis/server/logging"
"github.com/runatlantis/atlantis/server/static"
"github.com/urfave/cli"
"github.com/urfave/negroni"
)
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"
)
// Server runs the Atlantis web server.
type Server struct {
AtlantisVersion string
Router *mux.Router
Port int
CommandRunner *events.DefaultCommandRunner
Logger *logging.SimpleLogger
Locker locking.Locker
EventsController *EventsController
LocksController *LocksController
IndexTemplate TemplateWriter
LockDetailTemplate TemplateWriter
SSLCertFile string
SSLKeyFile string
}
// UserConfig holds config values passed in by the user.
// The mapstructure tags correspond to flags in cmd/server.go and are used when
// the config is parsed from a YAML file.
type UserConfig struct {
AllowForkPRs bool `mapstructure:"allow-fork-prs"`
AllowRepoConfig bool `mapstructure:"allow-repo-config"`
AtlantisURL string `mapstructure:"atlantis-url"`
BitbucketBaseURL string `mapstructure:"bitbucket-base-url"`
BitbucketToken string `mapstructure:"bitbucket-token"`
BitbucketUser string `mapstructure:"bitbucket-user"`
BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"`
DataDir string `mapstructure:"data-dir"`
GithubHostname string `mapstructure:"gh-hostname"`
GithubToken string `mapstructure:"gh-token"`
GithubUser string `mapstructure:"gh-user"`
GithubWebhookSecret string `mapstructure:"gh-webhook-secret"`
GitlabHostname string `mapstructure:"gitlab-hostname"`
GitlabToken string `mapstructure:"gitlab-token"`
GitlabUser string `mapstructure:"gitlab-user"`
GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"`
LogLevel string `mapstructure:"log-level"`
Port int `mapstructure:"port"`
RepoWhitelist string `mapstructure:"repo-whitelist"`
// RequireApproval is whether to require pull request approval before
// allowing terraform apply's to be run.
RequireApproval bool `mapstructure:"require-approval"`
SlackToken string `mapstructure:"slack-token"`
SSLCertFile string `mapstructure:"ssl-cert-file"`
SSLKeyFile string `mapstructure:"ssl-key-file"`
Webhooks []WebhookConfig `mapstructure:"webhooks"`
}
// Config holds config for server that isn't passed in by the user.
type Config struct {
AllowForkPRsFlag string
AllowRepoConfigFlag string
AtlantisVersion 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"`
// 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"`
}
// 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) {
var supportedVCSHosts []models.VCSHostType
var githubClient *vcs.GithubClient
var gitlabClient *vcs.GitlabClient
var bitbucketCloudClient *bitbucketcloud.Client
var bitbucketServerClient *bitbucketserver.Client
if userConfig.GithubUser != "" {
supportedVCSHosts = append(supportedVCSHosts, models.Github)
var err error
githubClient, err = vcs.NewGithubClient(userConfig.GithubHostname, userConfig.GithubUser, userConfig.GithubToken)
if err != nil {
return nil, err
}
}
if userConfig.GitlabUser != "" {
supportedVCSHosts = append(supportedVCSHosts, models.Gitlab)
gitlabClient = &vcs.GitlabClient{
Client: gitlab.NewClient(nil, userConfig.GitlabToken),
}
// If not using gitlab.com we need to set the URL to the API.
if userConfig.GitlabHostname != "gitlab.com" {
// Check if they've also provided a scheme so we don't prepend it
// again.
scheme := "https"
schemeSplit := strings.Split(userConfig.GitlabHostname, "://")
if len(schemeSplit) > 1 {
scheme = schemeSplit[0]
userConfig.GitlabHostname = schemeSplit[1]
}
apiURL := fmt.Sprintf("%s://%s/api/v4/", scheme, userConfig.GitlabHostname)
if err := gitlabClient.Client.SetBaseURL(apiURL); err != nil {
return nil, errors.Wrapf(err, "setting GitLab API URL: %s", apiURL)
}
}
}
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")
}
}
}
var webhooksConfig []webhooks.Config
for _, c := range userConfig.Webhooks {
config := webhooks.Config{
Channel: c.Channel,
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.NewDefaultClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient)
commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient}
terraformClient, err := terraform.NewClient(userConfig.DataDir)
// 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, "initializing terraform")
}
markdownRenderer := &events.MarkdownRenderer{}
boltdb, err := boltdb.New(userConfig.DataDir)
if err != nil {
return nil, err
}
lockingClient := locking.NewClient(boltdb)
workingDirLocker := events.NewDefaultWorkingDirLocker()
workingDir := &events.FileWorkspace{
DataDir: userConfig.DataDir,
}
projectLocker := &events.DefaultProjectLocker{
Locker: lockingClient,
}
underlyingRouter := mux.NewRouter()
router := &Router{
AtlantisURL: userConfig.AtlantisURL,
LockViewRouteIDQueryParam: LockViewRouteIDQueryParam,
LockViewRouteName: LockViewRouteName,
Underlying: underlyingRouter,
}
pullClosedExecutor := &events.PullClosedExecutor{
VCSClient: vcsClient,
Locker: lockingClient,
WorkingDir: workingDir,
}
logger := logging.NewSimpleLogger("server", nil, false, logging.ToLogLevel(userConfig.LogLevel))
eventParser := &events.EventParser{
GithubUser: userConfig.GithubUser,
GithubToken: userConfig.GithubToken,
GitlabUser: userConfig.GitlabUser,
GitlabToken: userConfig.GitlabToken,
BitbucketUser: userConfig.BitbucketUser,
BitbucketToken: userConfig.BitbucketToken,
BitbucketServerURL: userConfig.BitbucketBaseURL,
}
commentParser := &events.CommentParser{
GithubUser: userConfig.GithubUser,
GithubToken: userConfig.GithubToken,
GitlabUser: userConfig.GitlabUser,
GitlabToken: userConfig.GitlabToken,
}
defaultTfVersion := terraformClient.Version()
commandRunner := &events.DefaultCommandRunner{
VCSClient: vcsClient,
GithubPullGetter: githubClient,
GitlabMergeRequestGetter: gitlabClient,
CommitStatusUpdater: commitStatusUpdater,
EventParser: eventParser,
MarkdownRenderer: markdownRenderer,
Logger: logger,
AllowForkPRs: userConfig.AllowForkPRs,
AllowForkPRsFlag: config.AllowForkPRsFlag,
ProjectCommandBuilder: &events.DefaultProjectCommandBuilder{
ParserValidator: &yaml.ParserValidator{},
ProjectFinder: &events.DefaultProjectFinder{},
VCSClient: vcsClient,
WorkingDir: workingDir,
WorkingDirLocker: workingDirLocker,
AllowRepoConfig: userConfig.AllowRepoConfig,
AllowRepoConfigFlag: config.AllowRepoConfigFlag,
PendingPlanFinder: &events.PendingPlanFinder{},
CommentBuilder: commentParser,
},
ProjectCommandRunner: &events.DefaultProjectCommandRunner{
Locker: projectLocker,
LockURLGenerator: router,
InitStepRunner: &runtime.InitStepRunner{
TerraformExecutor: terraformClient,
DefaultTFVersion: defaultTfVersion,
},
PlanStepRunner: &runtime.PlanStepRunner{
TerraformExecutor: terraformClient,
DefaultTFVersion: defaultTfVersion,
},
ApplyStepRunner: &runtime.ApplyStepRunner{
TerraformExecutor: terraformClient,
},
RunStepRunner: &runtime.RunStepRunner{
DefaultTFVersion: defaultTfVersion,
},
PullApprovedChecker: vcsClient,
WorkingDir: workingDir,
Webhooks: webhooksManager,
WorkingDirLocker: workingDirLocker,
RequireApprovalOverride: userConfig.RequireApproval,
},
}
repoWhitelist, err := events.NewRepoWhitelistChecker(userConfig.RepoWhitelist)
if err != nil {
return nil, err
}
locksController := &LocksController{
AtlantisVersion: config.AtlantisVersion,
Locker: lockingClient,
Logger: logger,
VCSClient: vcsClient,
LockDetailTemplate: lockTemplate,
WorkingDir: workingDir,
WorkingDirLocker: workingDirLocker,
}
eventsController := &EventsController{
CommandRunner: commandRunner,
PullCleaner: pullClosedExecutor,
Parser: eventParser,
CommentParser: commentParser,
Logger: logger,
GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret),
GithubRequestValidator: &DefaultGithubRequestValidator{},
GitlabRequestParserValidator: &DefaultGitlabRequestParserValidator{},
GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret),
RepoWhitelistChecker: repoWhitelist,
SupportedVCSHosts: supportedVCSHosts,
VCSClient: vcsClient,
BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret),
}
return &Server{
AtlantisVersion: config.AtlantisVersion,
Router: underlyingRouter,
Port: userConfig.Port,
CommandRunner: commandRunner,
Logger: logger,
Locker: lockingClient,
EventsController: eventsController,
LocksController: locksController,
IndexTemplate: indexTemplate,
LockDetailTemplate: lockTemplate,
SSLKeyFile: userConfig.SSLKeyFile,
SSLCertFile: userConfig.SSLCertFile,
}, 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.PathPrefix("/static/").Handler(http.FileServer(&assetfs.AssetFS{Asset: static.Asset, AssetDir: static.AssetDir, AssetInfo: static.AssetInfo}))
s.Router.HandleFunc("/events", s.EventsController.Post).Methods("POST")
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)
n := negroni.New(&negroni.Recovery{
Logger: log.New(os.Stdout, "", log.LstdFlags),
PrintStack: false,
StackAll: false,
StackSize: 1024 * 8,
}, NewRequestLogger(s.Logger))
n.UseHandler(s.Router)
// 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)
server := &http.Server{Addr: fmt.Sprintf(":%d", s.Port), Handler: n}
go func() {
s.Logger.Warn("Atlantis started - listening on port %v", s.Port)
var err error
if s.SSLCertFile != "" && s.SSLKeyFile != "" {
err = server.ListenAndServeTLS(s.SSLCertFile, s.SSLKeyFile)
} else {
err = server.ListenAndServe()
}
if err != nil {
// When shutdown safely, there will be no error.
s.Logger.Err(err.Error())
}
}()
<-stop
s.Logger.Warn("Received interrupt. Safely shutting down")
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second) // nolint: vet
if err := server.Shutdown(ctx); err != nil {
return cli.NewExitError(fmt.Sprintf("while shutting down: %s", err), 1)
}
return nil
}
// 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 []LockIndexData
for id, v := range locks {
lockURL, _ := s.Router.Get(LockViewRouteName).URL("id", url.QueryEscape(id))
lockResults = append(lockResults, LockIndexData{
LockURL: lockURL.String(),
RepoFullName: v.Project.RepoFullName,
PullNum: v.Pull.Num,
Time: v.Time,
})
}
// nolint: errcheck
s.IndexTemplate.Execute(w, IndexData{
Locks: lockResults,
AtlantisVersion: s.AtlantisVersion,
})
}
// Healthz returns the health check response. It always returns a 200 currently.
func (s *Server) Healthz(w http.ResponseWriter, _ *http.Request) {
data, err := json.MarshalIndent(&struct {
Status string `json:"status"`
}{
Status: "ok",
}, "", " ")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Error creating status json response: %s", err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data) // nolint: errcheck
}