-
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 all 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 |
---|---|---|
@@ -0,0 +1,233 @@ | ||
package controllers | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"io/ioutil" | ||
"net/http" | ||
"strings" | ||
|
||
"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/logging" | ||
"github.com/uber-go/tally" | ||
"gopkg.in/go-playground/validator.v9" | ||
) | ||
|
||
const atlantisTokenHeader = "X-Atlantis-Token" | ||
|
||
type APIController struct { | ||
APISecret []byte | ||
Locker locking.Locker | ||
Logger logging.SimpleLogging | ||
Parser events.EventParsing | ||
ProjectCommandBuilder events.ProjectCommandBuilder | ||
ProjectPlanCommandRunner events.ProjectPlanCommandRunner | ||
ProjectApplyCommandRunner events.ProjectApplyCommandRunner | ||
RepoAllowlistChecker *events.RepoAllowlistChecker | ||
Scope tally.Scope | ||
VCSClient vcs.Client | ||
} | ||
|
||
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 (a *APIController) apiReportError(w http.ResponseWriter, code int, err error) { | ||
response, _ := json.Marshal(map[string]string{ | ||
"error": err.Error(), | ||
}) | ||
a.respond(w, logging.Warn, code, string(response)) | ||
} | ||
|
||
func (a *APIController) Plan(w http.ResponseWriter, r *http.Request) { | ||
w.Header().Set("Content-Type", "application/json") | ||
|
||
request, ctx, code, err := a.apiParseAndValidate(r) | ||
if err != nil { | ||
a.apiReportError(w, code, err) | ||
return | ||
} | ||
|
||
result, err := a.apiPlan(request, ctx) | ||
if err != nil { | ||
a.apiReportError(w, http.StatusInternalServerError, err) | ||
return | ||
} | ||
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0) // nolint: errcheck | ||
if result.HasErrors() { | ||
code = http.StatusInternalServerError | ||
} | ||
|
||
// TODO: make a better response | ||
response, err := json.Marshal(result) | ||
if err != nil { | ||
a.apiReportError(w, http.StatusInternalServerError, err) | ||
return | ||
} | ||
a.respond(w, logging.Debug, code, string(response)) | ||
} | ||
|
||
func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) { | ||
w.Header().Set("Content-Type", "application/json") | ||
|
||
request, ctx, code, err := a.apiParseAndValidate(r) | ||
if err != nil { | ||
a.apiReportError(w, code, err) | ||
return | ||
} | ||
|
||
// We must first make the plan for all projects | ||
_, err = a.apiPlan(request, ctx) | ||
if err != nil { | ||
a.apiReportError(w, http.StatusInternalServerError, err) | ||
return | ||
} | ||
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0) // nolint: errcheck | ||
|
||
// We can now prepare and run the apply step | ||
result, err := a.apiApply(request, ctx) | ||
if err != nil { | ||
a.apiReportError(w, http.StatusInternalServerError, err) | ||
return | ||
} | ||
if result.HasErrors() { | ||
code = http.StatusInternalServerError | ||
} | ||
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. Seems like the apiApply function call and the next 2 if statements are the only differences when compared to the Plan function. Can we combine these 2 functions together and use an if statement to check if it should do an apply or not? That way it would DRY up the code a bit 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. I understand there is plenty of room for improvement, but I'm less sure about the specific change you are proposing. Could you suggest the change you described to avoid confusion? Much appreciated! |
||
|
||
response, err := json.Marshal(result) | ||
if err != nil { | ||
a.apiReportError(w, http.StatusInternalServerError, err) | ||
return | ||
} | ||
a.respond(w, logging.Debug, code, string(response)) | ||
} | ||
|
||
func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*command.Result, error) { | ||
cmds, err := request.getCommands(ctx, a.ProjectCommandBuilder.BuildPlanCommands) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
var projectResults []command.ProjectResult | ||
for _, cmd := range cmds { | ||
res := a.ProjectPlanCommandRunner.Plan(cmd) | ||
projectResults = append(projectResults, res) | ||
} | ||
return &command.Result{ProjectResults: projectResults}, nil | ||
} | ||
|
||
func (a *APIController) apiApply(request *APIRequest, ctx *command.Context) (*command.Result, error) { | ||
cmds, err := request.getCommands(ctx, a.ProjectCommandBuilder.BuildApplyCommands) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
var projectResults []command.ProjectResult | ||
for _, cmd := range cmds { | ||
res := a.ProjectApplyCommandRunner.Apply(cmd) | ||
projectResults = append(projectResults, res) | ||
} | ||
return &command.Result{ProjectResults: projectResults}, nil | ||
} | ||
|
||
func (a *APIController) apiParseAndValidate(r *http.Request) (*APIRequest, *command.Context, int, error) { | ||
if len(a.APISecret) == 0 { | ||
return nil, nil, http.StatusBadRequest, fmt.Errorf("ignoring request since API is disabled") | ||
} | ||
|
||
// Validate the secret token | ||
secret := r.Header.Get(atlantisTokenHeader) | ||
if secret != string(a.APISecret) { | ||
return nil, nil, http.StatusUnauthorized, fmt.Errorf("header %s did not match expected secret", atlantisTokenHeader) | ||
} | ||
|
||
// Parse the JSON payload | ||
bytes, err := ioutil.ReadAll(r.Body) | ||
if err != nil { | ||
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to read request") | ||
} | ||
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 := a.VCSClient.GetCloneURL(VCSHostType, request.Repository) | ||
if err != nil { | ||
return nil, nil, http.StatusInternalServerError, err | ||
} | ||
|
||
baseRepo, err := a.Parser.ParseAPIPlanRequest(VCSHostType, request.Repository, cloneURL) | ||
if err != nil { | ||
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to parse request: %v", err) | ||
} | ||
|
||
// Check if the repo is allowlisted | ||
if !a.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: a.Scope, | ||
Log: a.Logger, | ||
}, http.StatusOK, nil | ||
} | ||
|
||
func (a *APIController) respond(w http.ResponseWriter, lvl logging.LogLevel, responseCode int, format string, args ...interface{}) { | ||
response := fmt.Sprintf(format, args...) | ||
a.Logger.Log(lvl, response) | ||
w.WriteHeader(responseCode) | ||
fmt.Fprintln(w, response) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
package controllers_test | ||
|
||
import ( | ||
"bytes" | ||
"encoding/json" | ||
"github.com/runatlantis/atlantis/server/events/command" | ||
"github.com/runatlantis/atlantis/server/events/models" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
|
||
. "github.com/petergtz/pegomock" | ||
"github.com/runatlantis/atlantis/server/controllers" | ||
. "github.com/runatlantis/atlantis/server/core/locking/mocks" | ||
"github.com/runatlantis/atlantis/server/events" | ||
. "github.com/runatlantis/atlantis/server/events/mocks" | ||
. "github.com/runatlantis/atlantis/server/events/mocks/matchers" | ||
. "github.com/runatlantis/atlantis/server/events/vcs/mocks" | ||
"github.com/runatlantis/atlantis/server/logging" | ||
"github.com/runatlantis/atlantis/server/metrics" | ||
. "github.com/runatlantis/atlantis/testing" | ||
) | ||
|
||
const atlantisTokenHeader = "X-Atlantis-Token" | ||
const atlantisToken = "token" | ||
|
||
func TestAPIController_Plan(t *testing.T) { | ||
ac, projectCommandBuilder, projectCommandRunner := setup(t) | ||
body, _ := json.Marshal(controllers.APIRequest{ | ||
Repository: "Repo", | ||
Ref: "main", | ||
Type: "Gitlab", | ||
Projects: []string{"default"}, | ||
}) | ||
req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) | ||
req.Header.Set(atlantisTokenHeader, atlantisToken) | ||
w := httptest.NewRecorder() | ||
ac.Plan(w, req) | ||
ResponseContains(t, w, http.StatusOK, "") | ||
projectCommandBuilder.VerifyWasCalledOnce().BuildPlanCommands(AnyPtrToEventsCommandContext(), AnyPtrToEventsCommentCommand()) | ||
projectCommandRunner.VerifyWasCalledOnce().Plan(AnyModelsProjectCommandContext()) | ||
} | ||
|
||
func TestAPIController_Apply(t *testing.T) { | ||
ac, projectCommandBuilder, projectCommandRunner := setup(t) | ||
body, _ := json.Marshal(controllers.APIRequest{ | ||
Repository: "Repo", | ||
Ref: "main", | ||
Type: "Gitlab", | ||
Projects: []string{"default"}, | ||
}) | ||
req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) | ||
req.Header.Set(atlantisTokenHeader, atlantisToken) | ||
w := httptest.NewRecorder() | ||
ac.Apply(w, req) | ||
ResponseContains(t, w, http.StatusOK, "") | ||
projectCommandBuilder.VerifyWasCalledOnce().BuildApplyCommands(AnyPtrToEventsCommandContext(), AnyPtrToEventsCommentCommand()) | ||
projectCommandRunner.VerifyWasCalledOnce().Plan(AnyModelsProjectCommandContext()) | ||
projectCommandRunner.VerifyWasCalledOnce().Apply(AnyModelsProjectCommandContext()) | ||
} | ||
|
||
func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) { | ||
RegisterMockTestingT(t) | ||
locker := NewMockLocker() | ||
logger := logging.NewNoopLogger(t) | ||
scope, _, _ := metrics.NewLoggingScope(logger, "null") | ||
parser := NewMockEventParsing() | ||
vcsClient := NewMockClient() | ||
repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") | ||
Ok(t, err) | ||
|
||
projectCommandBuilder := NewMockProjectCommandBuilder() | ||
When(projectCommandBuilder.BuildPlanCommands(AnyPtrToEventsCommandContext(), AnyPtrToEventsCommentCommand())). | ||
ThenReturn([]command.ProjectContext{{ | ||
CommandName: command.Plan, | ||
}}, nil) | ||
When(projectCommandBuilder.BuildApplyCommands(AnyPtrToEventsCommandContext(), AnyPtrToEventsCommentCommand())). | ||
ThenReturn([]command.ProjectContext{{ | ||
CommandName: command.Apply, | ||
}}, nil) | ||
|
||
projectCommandRunner := NewMockProjectCommandRunner() | ||
When(projectCommandRunner.Plan(AnyModelsProjectCommandContext())).ThenReturn(command.ProjectResult{ | ||
PlanSuccess: &models.PlanSuccess{}, | ||
}) | ||
When(projectCommandRunner.Apply(AnyModelsProjectCommandContext())).ThenReturn(command.ProjectResult{ | ||
ApplySuccess: "success", | ||
}) | ||
|
||
ac := controllers.APIController{ | ||
APISecret: []byte(atlantisToken), | ||
Locker: locker, | ||
Logger: logger, | ||
Scope: scope, | ||
Parser: parser, | ||
ProjectCommandBuilder: projectCommandBuilder, | ||
ProjectPlanCommandRunner: projectCommandRunner, | ||
ProjectApplyCommandRunner: projectCommandRunner, | ||
VCSClient: vcsClient, | ||
RepoAllowlistChecker: repoAllowlistChecker, | ||
} | ||
return ac, projectCommandBuilder, projectCommandRunner | ||
} |
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!