Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the /plan and /apply endpoints #997

Merged
merged 11 commits into from
Jul 27, 2022
4 changes: 4 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const (
GitlabTokenFlag = "gitlab-token"
GitlabUserFlag = "gitlab-user"
GitlabWebhookSecretFlag = "gitlab-webhook-secret" // nolint: gosec
APISecretFlag = "api-secret"
HidePrevPlanComments = "hide-prev-plan-comments"
LogLevelFlag = "log-level"
ParallelPoolSize = "parallel-pool-size"
Expand Down Expand Up @@ -259,6 +260,9 @@ var stringFlags = map[string]stringFlag{
"This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " +
"Should be specified via the ATLANTIS_GITLAB_WEBHOOK_SECRET environment variable.",
},
APISecretFlag: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine with me, but my use of this feature would probably restrict the plan and apply via HTTP rules at the ingress layer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback!

description: "Secret to validate requests made to the API",
},
LogLevelFlag: {
description: "Log level. Either debug, info, warn, or error.",
defaultValue: DefaultLogLevel,
Expand Down
193 changes: 193 additions & 0 deletions server/controllers/events/events_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,28 @@
package events

import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"

"github.com/google/go-github/v31/github"
"github.com/mcdafydd/go-azuredevops/azuredevops"
"github.com/microcosm-cc/bluemonday"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/core/locking"
"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/logging"
"github.com/uber-go/tally"
gitlab "github.com/xanzy/go-gitlab"
"gopkg.in/go-playground/validator.v9"
)

const githubHeader = "X-Github-Event"
Expand Down Expand Up @@ -86,6 +91,12 @@ type VCSEventsController struct {
// Azure DevOps Team Project. If empty, no request validation is done.
AzureDevopsWebhookBasicPassword []byte
AzureDevopsRequestValidator AzureDevopsRequestValidator

APISecret []byte
ProjectCommandBuilder events.ProjectCommandBuilder
ProjectPlanCommandRunner events.ProjectPlanCommandRunner
ProjectApplyCommandRunner events.ProjectApplyCommandRunner
Locker locking.Locker
}

// Post handles POST webhook requests.
Expand Down Expand Up @@ -713,3 +724,185 @@ func (e *VCSEventsController) commentNotAllowlisted(baseRepo models.Repo, pullNu
e.Logger.Err("unable to comment on pull request: %s", err)
}
}

type APIRequest struct {
Repository string `validate:"required"`
Ref string `validate:"required"`
Type string `validate:"required"`
Projects []string
Paths []struct {
Directory string
Workspace string
}
}

func (a *APIRequest) getCommands(ctx *command.Context, cmdBuilder func(*command.Context, *events.CommentCommand) ([]command.ProjectContext, error)) ([]command.ProjectContext, error) {
cc := make([]*events.CommentCommand, 0)

for _, project := range a.Projects {
cc = append(cc, &events.CommentCommand{
ProjectName: project,
})
}
for _, path := range a.Paths {
cc = append(cc, &events.CommentCommand{
RepoRelDir: strings.TrimRight(path.Directory, "/"),
Workspace: path.Workspace,
})
}

cmds := make([]command.ProjectContext, 0)
for _, commentCommand := range cc {
projectCmds, err := cmdBuilder(ctx, commentCommand)
if err != nil {
return nil, fmt.Errorf("Failed to build command: %v", err)
}
cmds = append(cmds, projectCmds...)
}

return cmds, nil
}

func (e *VCSEventsController) apiReportError(w http.ResponseWriter, code int, err error) {
response, _ := json.Marshal(map[string]string{
"error": err.Error(),
})
e.respond(w, logging.Warn, code, string(response))
}

func (e *VCSEventsController) APIPlan(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

request, ctx, code, err := e.apiParseAndValidate(w, r)

if err != nil {
e.apiReportError(w, code, err)
return
}

cmds, err := request.getCommands(ctx, e.ProjectCommandBuilder.BuildPlanCommands)
if err != nil {
e.apiReportError(w, http.StatusInternalServerError, err)
return
}

defer e.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0) // nolint: errcheck
var projectResults []command.ProjectResult
for _, cmd := range cmds {
res := e.ProjectPlanCommandRunner.Plan(cmd)
projectResults = append(projectResults, res)
}
result := command.Result{ProjectResults: projectResults}

if result.HasErrors() {
code = http.StatusInternalServerError
}

// TODO: make a better response
response, _ := json.Marshal(result)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could happen months or a year later the command.Result object becomes non JSON-able. Safer to check the error and return it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Updated.

e.respond(w, logging.Debug, code, string(response))
}

func (e *VCSEventsController) APIApply(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

request, ctx, code, err := e.apiParseAndValidate(w, r)

if err != nil {
e.apiReportError(w, code, err)
return
}

// We must first make the plan for all projects
cmds, err := request.getCommands(ctx, e.ProjectCommandBuilder.BuildPlanCommands)
if err != nil {
e.apiReportError(w, http.StatusInternalServerError, err)
return
}

defer e.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0) // nolint: errcheck
var projectResults []command.ProjectResult
for _, cmd := range cmds {
res := e.ProjectPlanCommandRunner.Plan(cmd)
projectResults = append(projectResults, res)
}
result := command.Result{ProjectResults: projectResults}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the function header to this line, it looks very similar to the ApiPlan function. Can we create a helper function that both ApiPlan and ApiApply can use in order to make this a little more DRY?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even using a switch case in a single function to combine both functions logic would be nice. If both of these functions are consolidated, it would make it much easier to add new workflow api endpoints in the future

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I made a first attempt to extract common code.


// We can now prepare and run the apply step
cmds, err = request.getCommands(ctx, e.ProjectCommandBuilder.BuildApplyCommands)
if err != nil {
e.apiReportError(w, http.StatusInternalServerError, err)
return
}
projectResults = nil
for _, cmd := range cmds {
res := e.ProjectApplyCommandRunner.Apply(cmd)
projectResults = append(projectResults, res)
}
result = command.Result{ProjectResults: projectResults}

if result.HasErrors() {
code = http.StatusInternalServerError
}

response, _ := json.Marshal(result)
e.respond(w, logging.Debug, code, string(response))
}

func (e *VCSEventsController) apiParseAndValidate(w http.ResponseWriter, r *http.Request) (*APIRequest, *command.Context, int, error) {
if len(e.APISecret) == 0 {
return nil, nil, http.StatusBadRequest, fmt.Errorf("Ignoring request since API is disabled")
}

// Validate the secret token
header := "X-Atlantis-Token"
secret := r.Header.Get(header)
if secret != string(e.APISecret) {
return nil, nil, http.StatusUnauthorized, fmt.Errorf("header %s did not match expected secret", header)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistency

Suggested change
return nil, nil, http.StatusUnauthorized, fmt.Errorf("header %s did not match expected secret", header)
return nil, nil, http.StatusUnauthorized, fmt.Errorf("Header %s did not match expected secret", header)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

}

// Parse the JSON payload
bytes, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to read request")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to read request")
return nil, nil, http.StatusBadRequest, fmt.Errorf("Failed to read request")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point on the consistency. I didn't pay attention to it since I was focused on resolving the conflicts. Updated all the error messages to lowercase since it's what our style guide prefers.

}
var request APIRequest
if err = json.Unmarshal(bytes, &request); err != nil {
return nil, nil, http.StatusBadRequest, fmt.Errorf("Failed to parse request: %v", err.Error())
}
if err = validator.New().Struct(request); err != nil {
return nil, nil, http.StatusBadRequest, fmt.Errorf("Request %q is missing fields", string(bytes))
}

VCSHostType, err := models.NewVCSHostType(request.Type)
if err != nil {
return nil, nil, http.StatusBadRequest, err
}
cloneURL, err := e.VCSClient.GetCloneURL(VCSHostType, request.Repository)
if err != nil {
return nil, nil, http.StatusInternalServerError, err
}

baseRepo, err := e.Parser.ParseAPIPlanRequest(request.Repository, cloneURL)
if err != nil {
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to parse request: %v", err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistency

Suggested change
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to parse request: %v", err)
return nil, nil, http.StatusBadRequest, fmt.Errorf("Failed to parse request: %v", err)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

}

// Check if the repo is whitelisted
if !e.RepoAllowlistChecker.IsAllowlisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) {
return nil, nil, http.StatusForbidden, fmt.Errorf("Repo not allowlisted")
}

return &request, &command.Context{
HeadRepo: baseRepo,
Pull: models.PullRequest{
Num: 0,
BaseBranch: request.Ref,
HeadBranch: request.Ref,
HeadCommit: request.Ref,
BaseRepo: baseRepo,
},
Scope: e.Scope,
Log: e.Logger,
}, http.StatusOK, nil
}
6 changes: 6 additions & 0 deletions server/events/event_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ type EventParsing interface {
// that returns a merge request.
ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest

ParseAPIPlanRequest(path, cloneURL string) (baseRepo models.Repo, err error)

// ParseBitbucketCloudPullEvent parses a pull request event from Bitbucket
// Cloud (bitbucket.org).
// pull is the parsed pull request.
Expand Down Expand Up @@ -303,6 +305,10 @@ type EventParser struct {
AzureDevopsUser string
}

func (e *EventParser) ParseAPIPlanRequest(repoFullName, cloneURL string) (models.Repo, error) {
return models.NewRepo(models.Gitlab, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken)
}

// GetBitbucketCloudPullEventType returns the type of the pull request
// event given the Bitbucket Cloud header.
func (e *EventParser) GetBitbucketCloudPullEventType(eventTypeHeader string) models.PullRequestEventType {
Expand Down
19 changes: 19 additions & 0 deletions server/events/mocks/mock_event_parsing.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions server/events/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,23 @@ func (h VCSHostType) String() string {
return "<missing String() implementation>"
}

func NewVCSHostType(t string) (VCSHostType, error) {
switch t {
case "Github":
return Github, nil
case "Gitlab":
return Gitlab, nil
case "BitbucketCloud":
return BitbucketCloud, nil
case "BitbucketServer":
return BitbucketServer, nil
case "AzureDevops":
return AzureDevops, nil
}

return -1, fmt.Errorf("%q is not a valid type", t)
}

// SplitRepoFullName splits a repo full name up into its owner and repo
// name segments. If the repoFullName is malformed, may return empty
// strings for owner or repo.
Expand Down
4 changes: 4 additions & 0 deletions server/events/vcs/azuredevops_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,7 @@ func GitStatusContextFromSrc(src string) *azuredevops.GitStatusContext {
Genre: &genre,
}
}

func (g *AzureDevopsClient) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) {
return "", fmt.Errorf("not yet implemented")
}
4 changes: 4 additions & 0 deletions server/events/vcs/bitbucketcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,7 @@ func (b *Client) SupportsSingleFileDownload(models.Repo) bool {
func (b *Client) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) {
return false, []byte{}, fmt.Errorf("Not Implemented")
}

func (b *Client) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) {
return "", fmt.Errorf("not yet implemented")
}
4 changes: 4 additions & 0 deletions server/events/vcs/bitbucketserver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,7 @@ func (b *Client) SupportsSingleFileDownload(repo models.Repo) bool {
func (b *Client) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) {
return false, []byte{}, fmt.Errorf("not implemented")
}

func (b *Client) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) {
return "", fmt.Errorf("not yet implemented")
}
1 change: 1 addition & 0 deletions server/events/vcs/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ type Client interface {
// if BaseRepo had one repo config file, its content will placed on the second return value
DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error)
SupportsSingleFileDownload(repo models.Repo) bool
GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error)
}
4 changes: 4 additions & 0 deletions server/events/vcs/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,7 @@ func (g *GithubClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []
func (g *GithubClient) SupportsSingleFileDownload(repo models.Repo) bool {
return true
}

func (g *GithubClient) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) {
return "", fmt.Errorf("not yet implemented")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed for plan/apply endpoints to work for github? If so can we also prioritize this before merging? Most users of Atlantis are probably on GitHub

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! Implemented.

}
8 changes: 8 additions & 0 deletions server/events/vcs/gitlab_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,11 @@ func (g *GitlabClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []
func (g *GitlabClient) SupportsSingleFileDownload(repo models.Repo) bool {
return true
}

func (g *GitlabClient) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) {
project, _, err := g.Client.Projects.GetProject(repo, nil)
if err != nil {
return "", err
}
return project.HTTPURLToRepo, nil
}
19 changes: 19 additions & 0 deletions server/events/vcs/mocks/mock_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions server/events/vcs/not_configured_vcs_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ func (a *NotConfiguredVCSClient) SupportsSingleFileDownload(repo models.Repo) bo
func (a *NotConfiguredVCSClient) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) {
return true, []byte{}, a.err()
}
func (a *NotConfiguredVCSClient) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) {
return "", a.err()
}
Loading