diff --git a/cmd/server.go b/cmd/server.go index 9880892cfc..2e26de3055 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -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" @@ -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: { + description: "Secret to validate requests made to the API", + }, LogLevelFlag: { description: "Log level. Either debug, info, warn, or error.", defaultValue: DefaultLogLevel, diff --git a/server/controllers/api_controller.go b/server/controllers/api_controller.go new file mode 100644 index 0000000000..3138419146 --- /dev/null +++ b/server/controllers/api_controller.go @@ -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 + } + + 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) +} diff --git a/server/controllers/api_controller_test.go b/server/controllers/api_controller_test.go new file mode 100644 index 0000000000..8cae678b63 --- /dev/null +++ b/server/controllers/api_controller_test.go @@ -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 +} diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 715fd87924..87cf264672 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -216,6 +216,8 @@ type EventParsing interface { // that returns a merge request. ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest + ParseAPIPlanRequest(vcsHostType models.VCSHostType, path string, cloneURL string) (baseRepo models.Repo, err error) + // ParseBitbucketCloudPullEvent parses a pull request event from Bitbucket // Cloud (bitbucket.org). // pull is the parsed pull request. @@ -303,6 +305,16 @@ type EventParser struct { AzureDevopsUser string } +func (e *EventParser) ParseAPIPlanRequest(vcsHostType models.VCSHostType, repoFullName string, cloneURL string) (models.Repo, error) { + switch vcsHostType { + case models.Github: + return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GithubUser, e.GithubToken) + case models.Gitlab: + return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken) + } + return models.Repo{}, fmt.Errorf("not implemented") +} + // GetBitbucketCloudPullEventType returns the type of the pull request // event given the Bitbucket Cloud header. func (e *EventParser) GetBitbucketCloudPullEventType(eventTypeHeader string) models.PullRequestEventType { diff --git a/server/events/mocks/mock_event_parsing.go b/server/events/mocks/mock_event_parsing.go index 14d3fbde95..54e8e6215a 100644 --- a/server/events/mocks/mock_event_parsing.go +++ b/server/events/mocks/mock_event_parsing.go @@ -228,6 +228,25 @@ func (mock *MockEventParsing) ParseGitlabMergeRequest(mr *go_gitlab.MergeRequest return ret0 } +func (mock *MockEventParsing) ParseAPIPlanRequest(vcsHostType models.VCSHostType, repoFullName string, cloneURL string) (models.Repo, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{vcsHostType, repoFullName, cloneURL} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAPIPlanRequest", params, []reflect.Type{reflect.TypeOf((*models.VCSHostType)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem()}) + var ret0 models.Repo + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.Repo) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + func (mock *MockEventParsing) ParseBitbucketCloudPullEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") diff --git a/server/events/models/models.go b/server/events/models/models.go index 719efbb4f7..7da8b57b48 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -314,6 +314,23 @@ func (h VCSHostType) String() string { return "" } +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. diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index abc7039d3f..531b99d2d0 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -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") +} diff --git a/server/events/vcs/bitbucketcloud/client.go b/server/events/vcs/bitbucketcloud/client.go index b40df730ef..4bdad53128 100644 --- a/server/events/vcs/bitbucketcloud/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -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") +} diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go index f1c1991e20..7449234187 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -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") +} diff --git a/server/events/vcs/client.go b/server/events/vcs/client.go index 5d932cb560..170ea1ad59 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -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) } diff --git a/server/events/vcs/github_client.go b/server/events/vcs/github_client.go index 7dc0ce3b51..8b396ad557 100644 --- a/server/events/vcs/github_client.go +++ b/server/events/vcs/github_client.go @@ -501,3 +501,12 @@ 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) { + parts := strings.Split(repo, "/") + repository, _, err := g.client.Repositories.Get(g.ctx, parts[0], parts[1]) + if err != nil { + return "", err + } + return repository.GetCloneURL(), nil +} diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go index 767d02b7d5..feeacc3573 100644 --- a/server/events/vcs/gitlab_client.go +++ b/server/events/vcs/gitlab_client.go @@ -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 +} diff --git a/server/events/vcs/mocks/mock_client.go b/server/events/vcs/mocks/mock_client.go index 0f70008577..3522cc8e0f 100644 --- a/server/events/vcs/mocks/mock_client.go +++ b/server/events/vcs/mocks/mock_client.go @@ -204,6 +204,25 @@ func (mock *MockClient) SupportsSingleFileDownload(_param0 models.Repo) bool { return ret0 } +func (mock *MockClient) GetCloneURL(_param0 models.VCSHostType, _param1 string) (string, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockClient().") + } + params := []pegomock.Param{_param0, _param1} + result := pegomock.GetGenericMockFrom(mock).Invoke("GetCloneURL", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 string + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + func (mock *MockClient) UpdateStatus(_param0 models.Repo, _param1 models.PullRequest, _param2 models.CommitStatus, _param3 string, _param4 string, _param5 string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") diff --git a/server/events/vcs/not_configured_vcs_client.go b/server/events/vcs/not_configured_vcs_client.go index fca7a662b8..c470ac7ef6 100644 --- a/server/events/vcs/not_configured_vcs_client.go +++ b/server/events/vcs/not_configured_vcs_client.go @@ -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() +} diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index 0ffb50dcfe..ddc3e35f28 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -95,3 +95,7 @@ func (d *ClientProxy) DownloadRepoConfigFile(pull models.PullRequest) (bool, []b func (d *ClientProxy) SupportsSingleFileDownload(repo models.Repo) bool { return d.clients[repo.VCSHost.Type].SupportsSingleFileDownload(repo) } + +func (d *ClientProxy) GetCloneURL(VCSHostType models.VCSHostType, repo string) (string, error) { + return d.clients[VCSHostType].GetCloneURL(VCSHostType, repo) +} diff --git a/server/server.go b/server/server.go index 31afea1f34..f7ba8d19f7 100644 --- a/server/server.go +++ b/server/server.go @@ -104,6 +104,7 @@ type Server struct { LocksController *controllers.LocksController StatusController *controllers.StatusController JobsController *controllers.JobsController + APIController *controllers.APIController IndexTemplate templates.TemplateWriter LockDetailTemplate templates.TemplateWriter ProjectJobsTemplate templates.TemplateWriter @@ -735,6 +736,18 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { KeyGenerator: controllers.JobIDKeyGenerator{}, StatsScope: statsScope.SubScope("api"), } + apiController := &controllers.APIController{ + APISecret: []byte(userConfig.APISecret), + Locker: lockingClient, + Logger: logger, + Parser: eventParser, + ProjectCommandBuilder: projectCommandBuilder, + ProjectPlanCommandRunner: instrumentedProjectCmdRunner, + ProjectApplyCommandRunner: instrumentedProjectCmdRunner, + RepoAllowlistChecker: repoAllowlist, + Scope: statsScope.SubScope("api"), + VCSClient: vcsClient, + } eventsController := &events_controllers.VCSEventsController{ CommandRunner: commandRunner, @@ -788,6 +801,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { LocksController: locksController, JobsController: jobsController, StatusController: statusController, + APIController: apiController, IndexTemplate: templates.IndexTemplate, LockDetailTemplate: templates.LockTemplate, ProjectJobsTemplate: templates.ProjectJobsTemplate, @@ -812,6 +826,8 @@ func (s *Server) Start() error { s.Router.HandleFunc("/status", s.StatusController.Get).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.VCSEventsController.Post).Methods("POST") + s.Router.HandleFunc("/api/plan", s.APIController.Plan).Methods("POST") + s.Router.HandleFunc("/api/apply", s.APIController.Apply).Methods("POST") s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET") s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET") s.Router.HandleFunc("/apply/lock", s.LocksController.LockApply).Methods("POST").Queries() diff --git a/server/user_config.go b/server/user_config.go index 9646c24645..84cf275498 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -46,6 +46,7 @@ type UserConfig struct { GitlabToken string `mapstructure:"gitlab-token"` GitlabUser string `mapstructure:"gitlab-user"` GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` + APISecret string `mapstructure:"api-secret"` HidePrevPlanComments bool `mapstructure:"hide-prev-plan-comments"` LogLevel string `mapstructure:"log-level"` ParallelPoolSize int `mapstructure:"parallel-pool-size"`