-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Changes from 8 commits
c8afddf
0f5d884
7f71142
40f76f6
439623f
34be22a
08c7a4d
df467aa
bd36139
4511dfd
c4438a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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" | ||||||
|
@@ -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. | ||||||
|
@@ -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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It could happen months or a year later the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consistency
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consistency
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the feedback! Implemented. |
||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the feedback!