diff --git a/CHANGELOG.md b/CHANGELOG.md index 06cbd2182..a37251af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # UNRELEASED +## Features + +* Adds support for the Run Tasks Integration API by @karvounis-form3 [#929](https://github.com/hashicorp/go-tfe/pull/929) + # v1.58.0 ## Enhancements diff --git a/README.md b/README.md index c7031efe8..0c0deac6c 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ This API client covers most of the existing HCP Terraform API calls and is updat - [x] Runs - [x] Run Events - [x] Run Tasks -- [ ] Run Tasks Integration +- [x] Run Tasks Integration - [x] Run Triggers - [x] SSH Keys - [x] Stability Policy diff --git a/errors.go b/errors.go index ceb45e290..67d912d4a 100644 --- a/errors.go +++ b/errors.go @@ -5,6 +5,7 @@ package tfe import ( "errors" + "fmt" ) // Generic errors applicable to all resources. @@ -219,6 +220,12 @@ var ( ErrInvalidModuleID = errors.New("invalid value for module ID") ErrInvalidRegistryName = errors.New(`invalid value for registry-name. It must be either "private" or "public"`) + + ErrInvalidCallbackURL = errors.New("invalid value for callback URL") + + ErrInvalidAccessToken = errors.New("invalid value for access token") + + ErrInvalidTaskResultsCallbackStatus = fmt.Errorf("invalid value for task result status. Must be either `%s`, `%s`, or `%s`", TaskFailed, TaskPassed, TaskRunning) ) var ( diff --git a/helper_test.go b/helper_test.go index 1a0cf57e4..4d3dd5cb3 100644 --- a/helper_test.go +++ b/helper_test.go @@ -18,6 +18,7 @@ import ( "io" "math/rand" "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -34,6 +35,8 @@ import ( const badIdentifier = "! / nope" //nolint const agentVersion = "1.3.0" +const testInitialClientToken = "insert-your-token-here" +const testTaskResultCallbackToken = "this-is-task-result-callback-token" var _testAccountDetails *TestAccountDetails @@ -2855,6 +2858,26 @@ func requireExactlyOneNotEmpty(t *testing.T, v ...any) { } } +func runTaskCallbackMockServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + return + } + if r.Header.Get("Accept") != ContentTypeJSONAPI { + t.Fatalf("unexpected accept header: %q", r.Header.Get("Accept")) + } + if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", testTaskResultCallbackToken) { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("Authorization") == fmt.Sprintf("Bearer %s", testInitialClientToken) { + t.Fatalf("authorization header is still the initial one: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("User-Agent") != "go-tfe" { + t.Fatalf("unexpected user agent header: %q", r.Header.Get("User-Agent")) + } + })) +} + // Useless key but enough to pass validation in the API const testGpgArmor string = ` -----BEGIN PGP PUBLIC KEY BLOCK----- diff --git a/run_task_request.go b/run_task_request.go new file mode 100644 index 000000000..b903f9486 --- /dev/null +++ b/run_task_request.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfe + +import ( + "time" +) + +// RunTaskRequest is the payload object that TFC/E sends to the Run Task's URL. +// https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#common-properties +type RunTaskRequest struct { + AccessToken string `json:"access_token"` + Capabilitites RunTaskRequestCapabilitites `json:"capabilitites,omitempty"` + ConfigurationVersionDownloadURL string `json:"configuration_version_download_url,omitempty"` + ConfigurationVersionID string `json:"configuration_version_id,omitempty"` + IsSpeculative bool `json:"is_speculative"` + OrganizationName string `json:"organization_name"` + PayloadVersion int `json:"payload_version"` + PlanJSONAPIURL string `json:"plan_json_api_url,omitempty"` // Specific to post_plan, pre_apply or post_apply stage + RunAppURL string `json:"run_app_url"` + RunCreatedAt time.Time `json:"run_created_at"` + RunCreatedBy string `json:"run_created_by"` + RunID string `json:"run_id"` + RunMessage string `json:"run_message"` + Stage string `json:"stage"` + TaskResultCallbackURL string `json:"task_result_callback_url"` + TaskResultEnforcementLevel string `json:"task_result_enforcement_level"` + TaskResultID string `json:"task_result_id"` + VcsBranch string `json:"vcs_branch,omitempty"` + VcsCommitURL string `json:"vcs_commit_url,omitempty"` + VcsPullRequestURL string `json:"vcs_pull_request_url,omitempty"` + VcsRepoURL string `json:"vcs_repo_url,omitempty"` + WorkspaceAppURL string `json:"workspace_app_url"` + WorkspaceID string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name"` + WorkspaceWorkingDirectory string `json:"workspace_working_directory,omitempty"` +} + +// RunTaskRequestCapabilitites defines the capabilities that the caller supports. +type RunTaskRequestCapabilitites struct { + Outcomes bool `json:"outcomes"` +} diff --git a/run_tasks_integration.go b/run_tasks_integration.go new file mode 100644 index 000000000..a2b0b9191 --- /dev/null +++ b/run_tasks_integration.go @@ -0,0 +1,79 @@ +package tfe + +import ( + "context" + "net/http" +) + +// Compile-time proof of interface implementation. +var _ RunTasksIntegration = (*runTaskIntegration)(nil) + +// RunTasksIntegration describes all the Run Tasks Integration Callback API methods. +// +// TFE API docs: +// https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration +type RunTasksIntegration interface { + // Update sends updates to TFC/E Run Task Callback URL + Callback(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error +} + +// taskResultsCallback implements RunTasksIntegration. +type runTaskIntegration struct { + client *Client +} + +// TaskResultCallbackRequestOptions represents the TFC/E Task result callback request +// https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#request-body-1 +type TaskResultCallbackRequestOptions struct { + Type string `jsonapi:"primary,task-results"` + Status TaskResultStatus `jsonapi:"attr,status"` + Message string `jsonapi:"attr,message,omitempty"` + URL string `jsonapi:"attr,url,omitempty"` + Outcomes []*TaskResultOutcome `jsonapi:"relation,outcomes,omitempty"` +} + +// TaskResultOutcome represents a detailed TFC/E run task outcome, which improves result visibility and content in the TFC/E UI. +// https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#outcomes-payload-body +type TaskResultOutcome struct { + Type string `jsonapi:"primary,task-result-outcomes"` + OutcomeID string `jsonapi:"attr,outcome-id,omitempty"` + Description string `jsonapi:"attr,description,omitempty"` + Body string `jsonapi:"attr,body,omitempty"` + URL string `jsonapi:"attr,url,omitempty"` + Tags map[string][]*TaskResultTag `jsonapi:"attr,tags,omitempty"` +} + +// TaskResultTag can be used to enrich outcomes display list in TFC/E. +// https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#severity-and-status-tags +type TaskResultTag struct { + Label string `json:"label"` + Level string `json:"level,omitempty"` +} + +// Update sends updates to TFC/E Run Task Callback URL +func (s *runTaskIntegration) Callback(ctx context.Context, callbackURL, accessToken string, options TaskResultCallbackRequestOptions) error { + if !validString(&callbackURL) { + return ErrInvalidCallbackURL + } + if !validString(&accessToken) { + return ErrInvalidAccessToken + } + if err := options.valid(); err != nil { + return err + } + req, err := s.client.NewRequest(http.MethodPatch, callbackURL, &options) + if err != nil { + return err + } + // The PATCH request must use the token supplied in the originating request (access_token) for authentication. + // https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#request-headers-1 + req.Header.Set("Authorization", "Bearer "+accessToken) + return req.Do(ctx, nil) +} + +func (o *TaskResultCallbackRequestOptions) valid() error { + if o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning { + return ErrInvalidTaskResultsCallbackStatus + } + return nil +} diff --git a/run_tasks_integration_test.go b/run_tasks_integration_test.go new file mode 100644 index 000000000..d3f606067 --- /dev/null +++ b/run_tasks_integration_test.go @@ -0,0 +1,99 @@ +package tfe + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRunTasksIntegration_Validate runs a series of tests that test whether various TaskResultCallbackRequestOptions objects can be considered valid or not +func TestRunTasksIntegration_Validate(t *testing.T) { + t.Run("with an empty status", func(t *testing.T) { + opts := TaskResultCallbackRequestOptions{Status: ""} + err := opts.valid() + assert.EqualError(t, err, ErrInvalidTaskResultsCallbackStatus.Error()) + }) + t.Run("without valid Status options", func(t *testing.T) { + for _, s := range []TaskResultStatus{TaskPending, TaskErrored, "foo"} { + opts := TaskResultCallbackRequestOptions{Status: s} + err := opts.valid() + assert.EqualError(t, err, ErrInvalidTaskResultsCallbackStatus.Error()) + } + }) + t.Run("with valid Status options", func(t *testing.T) { + for _, s := range []TaskResultStatus{TaskFailed, TaskPassed, TaskRunning} { + opts := TaskResultCallbackRequestOptions{Status: s} + err := opts.valid() + require.NoError(t, err) + } + }) +} + +// TestTaskResultsCallbackRequestOptions_Marshal tests whether you can properly serialise a TaskResultCallbackRequestOptions object +// You may find the expected body here: https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#request-body-1 +func TestTaskResultsCallbackRequestOptions_Marshal(t *testing.T) { + opts := TaskResultCallbackRequestOptions{ + Status: TaskPassed, + Message: "4 passed, 0 skipped, 0 failed", + URL: "https://external.service.dev/terraform-plan-checker/run-i3Df5to9ELvibKpQ", + Outcomes: []*TaskResultOutcome{ + { + OutcomeID: "PRTNR-CC-TF-127", + Description: "ST-2942:S3 Bucket will not enforce MFA login on delete requests", + Body: "# Resolution for issue ST-2942\n\n## Impact\n\nFollow instructions in the [AWS S3 docs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html) to manually configure the MFA setting.\nā€”-- Payload truncated ā€”--", + URL: "https://external.service.dev/result/PRTNR-CC-TF-127", + Tags: map[string][]*TaskResultTag{ + "Status": {&TaskResultTag{Label: "Denied", Level: "error"}}, + "Severity": { + &TaskResultTag{Label: "High", Level: "error"}, + &TaskResultTag{Label: "Recoverable", Level: "info"}, + }, + "Cost Centre": {&TaskResultTag{Label: "IT-OPS"}}, + }, + }, + }, + } + require.NoError(t, opts.valid()) + reqBody, err := serializeRequestBody(&opts) + require.NoError(t, err) + expectedBody := `{"data":{"type":"task-results","attributes":{"message":"4 passed, 0 skipped, 0 failed","status":"passed","url":"https://external.service.dev/terraform-plan-checker/run-i3Df5to9ELvibKpQ"},"relationships":{"outcomes":{"data":[{"type":"task-result-outcomes","attributes":{"body":"# Resolution for issue ST-2942\n\n## Impact\n\nFollow instructions in the [AWS S3 docs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html) to manually configure the MFA setting.\nā€”-- Payload truncated ā€”--","description":"ST-2942:S3 Bucket will not enforce MFA login on delete requests","outcome-id":"PRTNR-CC-TF-127","tags":{"Cost Centre":[{"label":"IT-OPS"}],"Severity":[{"label":"High","level":"error"},{"label":"Recoverable","level":"info"}],"Status":[{"label":"Denied","level":"error"}]},"url":"https://external.service.dev/result/PRTNR-CC-TF-127"}}]}}}} +` + assert.Equal(t, reqBody.(*bytes.Buffer).String(), expectedBody) +} + +func TestRunTasksIntegration_ValidateCallback(t *testing.T) { + t.Run("with invalid callbackURL", func(t *testing.T) { + trc := runTaskIntegration{client: nil} + err := trc.Callback(context.Background(), "", "", TaskResultCallbackRequestOptions{}) + assert.EqualError(t, err, ErrInvalidCallbackURL.Error()) + }) + t.Run("with invalid accessToken", func(t *testing.T) { + trc := runTaskIntegration{client: nil} + err := trc.Callback(context.Background(), "https://app.terraform.io/foo", "", TaskResultCallbackRequestOptions{}) + assert.EqualError(t, err, ErrInvalidAccessToken.Error()) + }) +} + +func TestRunTasksIntegration_Callback(t *testing.T) { + ts := runTaskCallbackMockServer(t) + defer ts.Close() + + client, err := NewClient(&Config{ + RetryServerErrors: true, + Token: testInitialClientToken, + Address: ts.URL, + }) + require.NoError(t, err) + trc := runTaskIntegration{ + client: client, + } + req := RunTaskRequest{ + AccessToken: testTaskResultCallbackToken, + TaskResultCallbackURL: ts.URL, + } + err = trc.Callback(context.Background(), req.TaskResultCallbackURL, req.AccessToken, TaskResultCallbackRequestOptions{Status: TaskPassed}) + require.NoError(t, err) +} diff --git a/tfe.go b/tfe.go index 8b301722e..c35705312 100644 --- a/tfe.go +++ b/tfe.go @@ -158,6 +158,7 @@ type Client struct { Runs Runs RunEvents RunEvents RunTasks RunTasks + RunTasksIntegration RunTasksIntegration RunTriggers RunTriggers SSHKeys SSHKeys Stacks Stacks @@ -459,6 +460,7 @@ func NewClient(cfg *Config) (*Client, error) { client.Runs = &runs{client: client} client.RunEvents = &runEvents{client: client} client.RunTasks = &runTasks{client: client} + client.RunTasksIntegration = &runTaskIntegration{client: client} client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} client.Stacks = &stacks{client: client} @@ -607,7 +609,7 @@ func (c *Client) retryHTTPBackoff(min, max time.Duration, attemptNum int, resp * // // min and max are mainly used for bounding the jitter that will be added to // the reset time retrieved from the headers. But if the final wait time is -// less then min, min will be used instead. +// less than min, min will be used instead. func rateLimitBackoff(min, max time.Duration, resp *http.Response) time.Duration { // rnd is used to generate pseudo-random numbers. rnd := rand.New(rand.NewSource(time.Now().UnixNano()))