mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 19:38:24 +00:00
426 lines
18 KiB
Go
426 lines
18 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
|
|
|
|
import (
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
|
|
"github.com/google/go-github/github"
|
|
"github.com/lkysow/go-gitlab"
|
|
"github.com/pkg/errors"
|
|
"github.com/runatlantis/atlantis/server/events"
|
|
"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/logging"
|
|
)
|
|
|
|
const githubHeader = "X-Github-Event"
|
|
const gitlabHeader = "X-Gitlab-Event"
|
|
|
|
// bitbucketEventTypeHeader is the same in both cloud and server.
|
|
const bitbucketEventTypeHeader = "X-Event-Key"
|
|
const bitbucketCloudRequestIDHeader = "X-Request-UUID"
|
|
const bitbucketServerRequestIDHeader = "X-Request-ID"
|
|
const bitbucketServerSignatureHeader = "X-Hub-Signature"
|
|
|
|
// EventsController handles all webhook requests which signify 'events' in the
|
|
// VCS host, ex. GitHub.
|
|
type EventsController struct {
|
|
CommandRunner events.CommandRunner
|
|
PullCleaner events.PullCleaner
|
|
Logger *logging.SimpleLogger
|
|
Parser events.EventParsing
|
|
CommentParser events.CommentParsing
|
|
// GithubWebhookSecret is the secret added to this webhook via the GitHub
|
|
// UI that identifies this call as coming from GitHub. If empty, no
|
|
// request validation is done.
|
|
GithubWebhookSecret []byte
|
|
GithubRequestValidator GithubRequestValidator
|
|
GitlabRequestParserValidator GitlabRequestParserValidator
|
|
// GitlabWebhookSecret is the secret added to this webhook via the GitLab
|
|
// UI that identifies this call as coming from GitLab. If empty, no
|
|
// request validation is done.
|
|
GitlabWebhookSecret []byte
|
|
RepoWhitelistChecker *events.RepoWhitelistChecker
|
|
// SupportedVCSHosts is which VCS hosts Atlantis was configured upon
|
|
// startup to support.
|
|
SupportedVCSHosts []models.VCSHostType
|
|
VCSClient vcs.ClientProxy
|
|
TestingMode bool
|
|
// BitbucketWebhookSecret is the secret added to this webhook via the Bitbucket
|
|
// UI that identifies this call as coming from Bitbucket. If empty, no
|
|
// request validation is done.
|
|
BitbucketWebhookSecret []byte
|
|
}
|
|
|
|
// Post handles POST webhook requests.
|
|
func (e *EventsController) Post(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(githubHeader) != "" {
|
|
if !e.supportsHost(models.Github) {
|
|
e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support GitHub")
|
|
return
|
|
}
|
|
e.Logger.Debug("handling GitHub post")
|
|
e.handleGithubPost(w, r)
|
|
return
|
|
} else if r.Header.Get(gitlabHeader) != "" {
|
|
if !e.supportsHost(models.Gitlab) {
|
|
e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support GitLab")
|
|
return
|
|
}
|
|
e.Logger.Debug("handling GitLab post")
|
|
e.handleGitlabPost(w, r)
|
|
return
|
|
} else if r.Header.Get(bitbucketEventTypeHeader) != "" {
|
|
// Bitbucket Cloud and Server use the same event type header but they
|
|
// use different request ID headers.
|
|
if r.Header.Get(bitbucketCloudRequestIDHeader) != "" {
|
|
if !e.supportsHost(models.BitbucketCloud) {
|
|
e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket Cloud")
|
|
return
|
|
}
|
|
e.Logger.Debug("handling Bitbucket Cloud post")
|
|
e.handleBitbucketCloudPost(w, r)
|
|
return
|
|
} else if r.Header.Get(bitbucketServerRequestIDHeader) != "" {
|
|
if !e.supportsHost(models.BitbucketServer) {
|
|
e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket Server")
|
|
return
|
|
}
|
|
e.Logger.Debug("handling Bitbucket Server post")
|
|
e.handleBitbucketServerPost(w, r)
|
|
return
|
|
}
|
|
}
|
|
e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request")
|
|
}
|
|
|
|
func (e *EventsController) handleGithubPost(w http.ResponseWriter, r *http.Request) {
|
|
// Validate the request against the optional webhook secret.
|
|
payload, err := e.GithubRequestValidator.Validate(r, e.GithubWebhookSecret)
|
|
if err != nil {
|
|
e.respond(w, logging.Warn, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
e.Logger.Debug("request valid")
|
|
|
|
githubReqID := "X-Github-Delivery=" + r.Header.Get("X-Github-Delivery")
|
|
event, _ := github.ParseWebHook(github.WebHookType(r), payload)
|
|
switch event := event.(type) {
|
|
case *github.IssueCommentEvent:
|
|
e.Logger.Debug("handling as comment event")
|
|
e.HandleGithubCommentEvent(w, event, githubReqID)
|
|
case *github.PullRequestEvent:
|
|
e.Logger.Debug("handling as pull request event")
|
|
e.HandleGithubPullRequestEvent(w, event, githubReqID)
|
|
default:
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event %s", githubReqID)
|
|
}
|
|
}
|
|
|
|
func (e *EventsController) handleBitbucketCloudPost(w http.ResponseWriter, r *http.Request) {
|
|
eventType := r.Header.Get(bitbucketEventTypeHeader)
|
|
reqID := r.Header.Get(bitbucketCloudRequestIDHeader)
|
|
defer r.Body.Close() // nolint: errcheck
|
|
body, err := ioutil.ReadAll(r.Body)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID)
|
|
return
|
|
}
|
|
switch eventType {
|
|
case bitbucketcloud.PullCreatedHeader, bitbucketcloud.PullUpdatedHeader, bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader:
|
|
e.Logger.Debug("handling as pull request state changed event")
|
|
e.handleBitbucketCloudPullRequestEvent(w, eventType, body, reqID)
|
|
return
|
|
case bitbucketcloud.PullCommentCreatedHeader:
|
|
e.Logger.Debug("handling as comment created event")
|
|
e.HandleBitbucketCloudCommentEvent(w, body, reqID)
|
|
return
|
|
default:
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketCloudRequestIDHeader, reqID)
|
|
}
|
|
}
|
|
|
|
func (e *EventsController) handleBitbucketServerPost(w http.ResponseWriter, r *http.Request) {
|
|
eventType := r.Header.Get(bitbucketEventTypeHeader)
|
|
reqID := r.Header.Get(bitbucketServerRequestIDHeader)
|
|
sig := r.Header.Get(bitbucketServerSignatureHeader)
|
|
defer r.Body.Close() // nolint: errcheck
|
|
body, err := ioutil.ReadAll(r.Body)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketServerRequestIDHeader, reqID)
|
|
return
|
|
}
|
|
if len(e.BitbucketWebhookSecret) > 0 {
|
|
if err := bitbucketserver.ValidateSignature(body, sig, e.BitbucketWebhookSecret); err != nil {
|
|
e.respond(w, logging.Warn, http.StatusBadRequest, errors.Wrap(err, "request did not pass validation").Error())
|
|
return
|
|
}
|
|
}
|
|
switch eventType {
|
|
case bitbucketserver.PullCreatedHeader, bitbucketserver.PullMergedHeader, bitbucketserver.PullDeclinedHeader:
|
|
e.Logger.Debug("handling as pull request state changed event")
|
|
e.handleBitbucketServerPullRequestEvent(w, eventType, body, reqID)
|
|
return
|
|
case bitbucketserver.PullCommentCreatedHeader:
|
|
e.Logger.Debug("handling as comment created event")
|
|
e.HandleBitbucketServerCommentEvent(w, body, reqID)
|
|
return
|
|
default:
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketServerRequestIDHeader, reqID)
|
|
}
|
|
}
|
|
|
|
// HandleGithubCommentEvent handles comment events from GitHub where Atlantis
|
|
// commands can come from. It's exported to make testing easier.
|
|
func (e *EventsController) HandleGithubCommentEvent(w http.ResponseWriter, event *github.IssueCommentEvent, githubReqID string) {
|
|
if event.GetAction() != "created" {
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since action was not created %s", githubReqID)
|
|
return
|
|
}
|
|
|
|
baseRepo, user, pullNum, err := e.Parser.ParseGithubIssueCommentEvent(event)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Failed parsing event: %v %s", err, githubReqID)
|
|
return
|
|
}
|
|
|
|
// We pass in nil for maybeHeadRepo because the head repo data isn't
|
|
// available in the GithubIssueComment event.
|
|
e.handleCommentEvent(w, baseRepo, nil, nil, user, pullNum, event.Comment.GetBody(), models.Github)
|
|
}
|
|
|
|
// HandleBitbucketCloudCommentEvent handles comment events from Bitbucket.
|
|
func (e *EventsController) HandleBitbucketCloudCommentEvent(w http.ResponseWriter, body []byte, reqID string) {
|
|
pull, baseRepo, headRepo, user, comment, err := e.Parser.ParseBitbucketCloudPullCommentEvent(body)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID)
|
|
return
|
|
}
|
|
e.handleCommentEvent(w, baseRepo, &headRepo, &pull, user, pull.Num, comment, models.BitbucketCloud)
|
|
}
|
|
|
|
// HandleBitbucketServerCommentEvent handles comment events from Bitbucket.
|
|
func (e *EventsController) HandleBitbucketServerCommentEvent(w http.ResponseWriter, body []byte, reqID string) {
|
|
pull, baseRepo, headRepo, user, comment, err := e.Parser.ParseBitbucketServerPullCommentEvent(body)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID)
|
|
return
|
|
}
|
|
e.handleCommentEvent(w, baseRepo, &headRepo, &pull, user, pull.Num, comment, models.BitbucketCloud)
|
|
}
|
|
|
|
func (e *EventsController) handleBitbucketCloudPullRequestEvent(w http.ResponseWriter, eventType string, body []byte, reqID string) {
|
|
pull, baseRepo, headRepo, user, err := e.Parser.ParseBitbucketCloudPullEvent(body)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID)
|
|
return
|
|
}
|
|
pullEventType := e.Parser.GetBitbucketCloudPullEventType(eventType)
|
|
e.Logger.Info("identified event as type %q", pullEventType.String())
|
|
e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType)
|
|
}
|
|
|
|
func (e *EventsController) handleBitbucketServerPullRequestEvent(w http.ResponseWriter, eventType string, body []byte, reqID string) {
|
|
pull, baseRepo, headRepo, user, err := e.Parser.ParseBitbucketServerPullEvent(body)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketServerRequestIDHeader, reqID)
|
|
return
|
|
}
|
|
pullEventType := e.Parser.GetBitbucketServerPullEventType(eventType)
|
|
e.Logger.Info("identified event as type %q", pullEventType.String())
|
|
e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType)
|
|
}
|
|
|
|
// HandleGithubPullRequestEvent will delete any locks associated with the pull
|
|
// request if the event is a pull request closed event. It's exported to make
|
|
// testing easier.
|
|
func (e *EventsController) HandleGithubPullRequestEvent(w http.ResponseWriter, pullEvent *github.PullRequestEvent, githubReqID string) {
|
|
pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseGithubPullEvent(pullEvent)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s", err, githubReqID)
|
|
return
|
|
}
|
|
e.Logger.Info("identified event as type %q", pullEventType.String())
|
|
e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType)
|
|
}
|
|
|
|
func (e *EventsController) handlePullRequestEvent(w http.ResponseWriter, baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User, eventType models.PullRequestEventType) {
|
|
if !e.RepoWhitelistChecker.IsWhitelisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) {
|
|
// If the repo isn't whitelisted and we receive an opened pull request
|
|
// event we comment back on the pull request that the repo isn't
|
|
// whitelisted. This is because the user might be expecting Atlantis to
|
|
// autoplan. For other events, we just ignore them.
|
|
if eventType == models.OpenedPullEvent {
|
|
e.commentNotWhitelisted(baseRepo, pull.Num)
|
|
}
|
|
e.respond(w, logging.Debug, http.StatusForbidden, "Ignoring pull request event from non-whitelisted repo")
|
|
return
|
|
}
|
|
|
|
switch eventType {
|
|
case models.OpenedPullEvent, models.UpdatedPullEvent:
|
|
// If the pull request was opened or updated, we will try to autoplan.
|
|
|
|
// Respond with success and then actually execute the command asynchronously.
|
|
// We use a goroutine so that this function returns and the connection is
|
|
// closed.
|
|
fmt.Fprintln(w, "Processing...")
|
|
|
|
e.Logger.Info("executing autoplan")
|
|
if !e.TestingMode {
|
|
go e.CommandRunner.RunAutoplanCommand(baseRepo, headRepo, pull, user)
|
|
} else {
|
|
// When testing we want to wait for everything to complete.
|
|
e.CommandRunner.RunAutoplanCommand(baseRepo, headRepo, pull, user)
|
|
}
|
|
return
|
|
case models.ClosedPullEvent:
|
|
// If the pull request was closed, we delete locks.
|
|
if err := e.PullCleaner.CleanUpPull(baseRepo, pull); err != nil {
|
|
e.respond(w, logging.Error, http.StatusInternalServerError, "Error cleaning pull request: %s", err)
|
|
return
|
|
}
|
|
e.Logger.Info("deleted locks and workspace for repo %s, pull %d", baseRepo.FullName, pull.Num)
|
|
fmt.Fprintln(w, "Pull request cleaned successfully")
|
|
return
|
|
case models.OtherPullEvent:
|
|
// Else we ignore the event.
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring non-actionable pull request event")
|
|
return
|
|
}
|
|
}
|
|
|
|
func (e *EventsController) handleGitlabPost(w http.ResponseWriter, r *http.Request) {
|
|
event, err := e.GitlabRequestParserValidator.ParseAndValidate(r, e.GitlabWebhookSecret)
|
|
if err != nil {
|
|
e.respond(w, logging.Warn, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
e.Logger.Debug("request valid")
|
|
|
|
switch event := event.(type) {
|
|
case gitlab.MergeCommentEvent:
|
|
e.Logger.Debug("handling as comment event")
|
|
e.HandleGitlabCommentEvent(w, event)
|
|
case gitlab.MergeEvent:
|
|
e.Logger.Debug("handling as pull request event")
|
|
e.HandleGitlabMergeRequestEvent(w, event)
|
|
default:
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event")
|
|
}
|
|
|
|
}
|
|
|
|
// HandleGitlabCommentEvent handles comment events from GitLab where Atlantis
|
|
// commands can come from. It's exported to make testing easier.
|
|
func (e *EventsController) HandleGitlabCommentEvent(w http.ResponseWriter, event gitlab.MergeCommentEvent) {
|
|
// todo: can gitlab return the pull request here too?
|
|
baseRepo, headRepo, user, err := e.Parser.ParseGitlabMergeRequestCommentEvent(event)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing webhook: %s", err)
|
|
return
|
|
}
|
|
e.handleCommentEvent(w, baseRepo, &headRepo, nil, user, event.MergeRequest.IID, event.ObjectAttributes.Note, models.Gitlab)
|
|
}
|
|
|
|
func (e *EventsController) handleCommentEvent(w http.ResponseWriter, baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, comment string, vcsHost models.VCSHostType) {
|
|
parseResult := e.CommentParser.Parse(comment, vcsHost)
|
|
if parseResult.Ignore {
|
|
truncated := comment
|
|
truncateLen := 40
|
|
if len(truncated) > truncateLen {
|
|
truncated = comment[:truncateLen] + "..."
|
|
}
|
|
e.respond(w, logging.Debug, http.StatusOK, "Ignoring non-command comment: %q", truncated)
|
|
return
|
|
}
|
|
e.Logger.Info("parsed comment as %s", parseResult.Command)
|
|
|
|
// At this point we know it's a command we're not supposed to ignore, so now
|
|
// we check if this repo is allowed to run commands in the first place.
|
|
if !e.RepoWhitelistChecker.IsWhitelisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) {
|
|
e.commentNotWhitelisted(baseRepo, pullNum)
|
|
e.respond(w, logging.Warn, http.StatusForbidden, "Repo not whitelisted")
|
|
return
|
|
}
|
|
|
|
// If the command isn't valid or doesn't require processing, ex.
|
|
// "atlantis help" then we just comment back immediately.
|
|
// We do this here rather than earlier because we need access to the pull
|
|
// variable to comment back on the pull request.
|
|
if parseResult.CommentResponse != "" {
|
|
if err := e.VCSClient.CreateComment(baseRepo, pullNum, parseResult.CommentResponse); err != nil {
|
|
e.Logger.Err("unable to comment on pull request: %s", err)
|
|
}
|
|
e.respond(w, logging.Info, http.StatusOK, "Commenting back on pull request")
|
|
return
|
|
}
|
|
|
|
e.Logger.Debug("executing command")
|
|
fmt.Fprintln(w, "Processing...")
|
|
if !e.TestingMode {
|
|
// Respond with success and then actually execute the command asynchronously.
|
|
// We use a goroutine so that this function returns and the connection is
|
|
// closed.
|
|
go e.CommandRunner.RunCommentCommand(baseRepo, maybeHeadRepo, maybePull, user, pullNum, parseResult.Command)
|
|
} else {
|
|
// When testing we want to wait for everything to complete.
|
|
e.CommandRunner.RunCommentCommand(baseRepo, maybeHeadRepo, maybePull, user, pullNum, parseResult.Command)
|
|
}
|
|
}
|
|
|
|
// HandleGitlabMergeRequestEvent will delete any locks associated with the pull
|
|
// request if the event is a merge request closed event. It's exported to make
|
|
// testing easier.
|
|
func (e *EventsController) HandleGitlabMergeRequestEvent(w http.ResponseWriter, event gitlab.MergeEvent) {
|
|
pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseGitlabMergeRequestEvent(event)
|
|
if err != nil {
|
|
e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing webhook: %s", err)
|
|
return
|
|
}
|
|
e.Logger.Info("identified event as type %q", pullEventType.String())
|
|
e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType)
|
|
}
|
|
|
|
// supportsHost returns true if h is in e.SupportedVCSHosts and false otherwise.
|
|
func (e *EventsController) supportsHost(h models.VCSHostType) bool {
|
|
for _, supported := range e.SupportedVCSHosts {
|
|
if h == supported {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *EventsController) respond(w http.ResponseWriter, lvl logging.LogLevel, code int, format string, args ...interface{}) {
|
|
response := fmt.Sprintf(format, args...)
|
|
e.Logger.Log(lvl, response)
|
|
w.WriteHeader(code)
|
|
fmt.Fprintln(w, response)
|
|
}
|
|
|
|
// commentNotWhitelisted comments on the pull request that the repo is not
|
|
// whitelisted.
|
|
func (e *EventsController) commentNotWhitelisted(baseRepo models.Repo, pullNum int) {
|
|
errMsg := "```\nError: This repo is not whitelisted for Atlantis.\n```"
|
|
if err := e.VCSClient.CreateComment(baseRepo, pullNum, errMsg); err != nil {
|
|
e.Logger.Err("unable to comment on pull request: %s", err)
|
|
}
|
|
}
|