From 7c965730b6893e0784a1185e552c61d4e864fb8a Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Tue, 12 Mar 2024 11:24:33 +0200 Subject: [PATCH 01/14] feat: add support for run task results callback --- errors.go | 13 ++++ run_task_request.go | 53 ++++++++++++++++ run_task_results_callback.go | 114 +++++++++++++++++++++++++++++++++++ tfe.go | 4 +- 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 run_task_request.go create mode 100644 run_task_results_callback.go diff --git a/errors.go b/errors.go index d096b80f4..b9abda96c 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,14 @@ 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") + + ErrInvalidTaskResultsCallbackType = errors.New("invalid value for task result type") + + ErrInvalidTaskResultsCallbackStatus = errors.New(fmt.Sprintf("invalid value for task result status. Must be either `%s`, `%s`, or `%s`", TaskFailed, TaskPassed, TaskRunning)) ) var ( @@ -372,4 +381,8 @@ var ( ErrRequiredRawState = errors.New("RawState is required") ErrStateVersionUploadNotSupported = errors.New("upload not supported by this version of Terraform Enterprise") + + ErrRequiredCallbackData = errors.New("data object is required for TFE run task callback") + + ErrRequiredCallbackDataAttributes = errors.New("data attributes object is required for TFE run task callback") ) diff --git a/run_task_request.go b/run_task_request.go new file mode 100644 index 000000000..227de7188 --- /dev/null +++ b/run_task_request.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfe + +import ( + "time" +) + +const ( + // RunTaskRegistrationRunId is the string that TFC/E sends when it just wants to register the Run Task. + RunTaskRegistrationRunId = "run-xxxxxxxxxxxxxxxx" +) + +// 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"` +} + +// IsEndpointValidation returns true if this is a Request from TFC/E to validate and register this API endpoint. +func (r RunTaskRequest) IsEndpointValidation() bool { + return r.RunID == RunTaskRegistrationRunId +} diff --git a/run_task_results_callback.go b/run_task_results_callback.go new file mode 100644 index 000000000..8c939cf82 --- /dev/null +++ b/run_task_results_callback.go @@ -0,0 +1,114 @@ +package tfe + +import ( + "context" + "net/http" +) + +// Compile-time proof of interface implementation. +var _ RunTasksCallback = (*taskResultsCallback)(nil) + +// RunTasksCallback 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 RunTasksCallback interface { + // Update sends updates to TFC/E Run Task Callback URL.. + Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultsCallbackOptions) error +} + +// taskResultsCallback implements RunTasksCallback. +type taskResultsCallback struct { + client *Client +} + +const ( + TaskResultsCallbackType = "task-results" +) + +// Update sends updates to TFC/E Run Task Callback URL +func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultsCallbackOptions) error { + if !validString(&callbackURL) { + return ErrInvalidCallbackURL + } + if !validString(&accessToken) { + return ErrInvalidAccessToken + } + 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) +} + +// TaskResultsCallbackOptions represents the options for a TFE Task result callback request +// https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#request-body-1 +type TaskResultsCallbackOptions struct { + Data *TaskResultsCallbackData `json:"data"` +} + +type TaskResultsCallbackData struct { + // Required: Must be set to `task-results` + Type *string `json:"type"` + // Required: Attributes of the Task Results Callback Response + Attributes *TaskResultsCallbackDataAttributes `json:"attributes"` + Relationships *TaskResultsCallbackRelationships `json:"relationships,omitempty"` +} + +type TaskResultsCallbackDataAttributes struct { + // Status Must be one of TaskFailed, TaskPassed or TaskRunning + Status TaskResultStatus `json:"status"` + // Message A short message describing the status of the task. + Message string `json:"message,omitempty"` + // URL that the user can use to get more information from the external service + URL string `json:"url,omitempty"` +} + +type TaskResultsCallbackRelationships struct { + // Outcomes A run task result may optionally contain one or more detailed outcomes, which improves result visibility and content in the Terraform Cloud user interface. + // https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#outcomes-payload-body + Outcomes *TaskResultsCallbackRelationshipsOutcomes `json:"outcomes"` +} + +type TaskResultsCallbackRelationshipsOutcomes struct { + Data []*TaskResultsCallbackRelationshipsOutcomesData `json:"data"` +} + +type TaskResultsCallbackRelationshipsOutcomesData struct { + Type string `json:"type"` + Attributes *TaskResultsCallbackRelationshipsOutcomesDataAttributes `json:"attributes"` +} + +type TaskResultsCallbackRelationshipsOutcomesDataAttributes struct { + OutcomeID string `json:"outcome-id"` + Description string `json:"description"` + Body string `json:"body,omitempty"` + URL string `json:"url,omitempty"` + Tags map[string][]*TaskResultsCallbackRelationshipsOutcomesDataTagsAttributes `json:"tags,omitempty"` +} + +// TaskResultsCallbackRelationshipsOutcomesDataTagsAttributes 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 TaskResultsCallbackRelationshipsOutcomesDataTagsAttributes struct { + Label string `json:"label"` + Level string `json:"level,omitempty"` +} + +func (o *TaskResultsCallbackOptions) valid() error { + if o.Data == nil { + return ErrRequiredCallbackData + } + if validStringID(o.Data.Type) && o.Data.Type != String(TaskResultsCallbackType) { + return ErrInvalidTaskResultsCallbackType + } + if o.Data.Attributes == nil { + return ErrRequiredCallbackDataAttributes + } + if o.Data.Attributes.Status != TaskFailed || o.Data.Attributes.Status != TaskPassed || o.Data.Attributes.Status != TaskRunning { + return ErrInvalidTaskResultsCallbackStatus + } + return nil +} diff --git a/tfe.go b/tfe.go index d849f59bc..c4f4b44b0 100644 --- a/tfe.go +++ b/tfe.go @@ -158,6 +158,7 @@ type Client struct { Runs Runs RunEvents RunEvents RunTasks RunTasks + RunTasksCallback RunTasksCallback RunTriggers RunTriggers SSHKeys SSHKeys StateVersionOutputs StateVersionOutputs @@ -458,6 +459,7 @@ func NewClient(cfg *Config) (*Client, error) { client.Runs = &runs{client: client} client.RunEvents = &runEvents{client: client} client.RunTasks = &runTasks{client: client} + client.RunTasksCallback = &taskResultsCallback{client: client} client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} client.StateVersionOutputs = &stateVersionOutputs{client: client} @@ -605,7 +607,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())) From 75e6c6886ee66a4c5a487b8cb5e2381b1cd1b506 Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Tue, 12 Mar 2024 11:33:26 +0200 Subject: [PATCH 02/14] chore: set Run Tasks Integration as supported --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 13c31ea18..2ca2250a7 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ This API client covers most of the existing Terraform Cloud API calls and is upd - [x] Runs - [x] Run Events - [x] Run Tasks -- [ ] Run Tasks Integration +- [x] Run Tasks Integration - [x] Run Triggers - [x] SSH Keys - [x] Stability Policy From 8e5aad75836539b425a06534cbba6c00ad829522 Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Wed, 3 Apr 2024 15:33:34 +0300 Subject: [PATCH 03/14] chore: replace json structs with jsonapi ones --- errors.go | 4 -- run_task_results_callback.go | 83 +++++++++++------------------------- 2 files changed, 26 insertions(+), 61 deletions(-) diff --git a/errors.go b/errors.go index b9abda96c..eec21102f 100644 --- a/errors.go +++ b/errors.go @@ -381,8 +381,4 @@ var ( ErrRequiredRawState = errors.New("RawState is required") ErrStateVersionUploadNotSupported = errors.New("upload not supported by this version of Terraform Enterprise") - - ErrRequiredCallbackData = errors.New("data object is required for TFE run task callback") - - ErrRequiredCallbackDataAttributes = errors.New("data attributes object is required for TFE run task callback") ) diff --git a/run_task_results_callback.go b/run_task_results_callback.go index 8c939cf82..1b6044484 100644 --- a/run_task_results_callback.go +++ b/run_task_results_callback.go @@ -13,8 +13,8 @@ var _ RunTasksCallback = (*taskResultsCallback)(nil) // TFE API docs: // https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration type RunTasksCallback interface { - // Update sends updates to TFC/E Run Task Callback URL.. - Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultsCallbackOptions) error + // Update sends updates to TFC/E Run Task Callback URL + Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error } // taskResultsCallback implements RunTasksCallback. @@ -27,7 +27,7 @@ const ( ) // Update sends updates to TFC/E Run Task Callback URL -func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultsCallbackOptions) error { +func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error { if !validString(&callbackURL) { return ErrInvalidCallbackURL } @@ -44,70 +44,39 @@ func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, ac return req.Do(ctx, nil) } -// TaskResultsCallbackOptions represents the options for a TFE Task result callback request +// 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 TaskResultsCallbackOptions struct { - Data *TaskResultsCallbackData `json:"data"` +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"` } -type TaskResultsCallbackData struct { - // Required: Must be set to `task-results` - Type *string `json:"type"` - // Required: Attributes of the Task Results Callback Response - Attributes *TaskResultsCallbackDataAttributes `json:"attributes"` - Relationships *TaskResultsCallbackRelationships `json:"relationships,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"` } -type TaskResultsCallbackDataAttributes struct { - // Status Must be one of TaskFailed, TaskPassed or TaskRunning - Status TaskResultStatus `json:"status"` - // Message A short message describing the status of the task. - Message string `json:"message,omitempty"` - // URL that the user can use to get more information from the external service - URL string `json:"url,omitempty"` -} - -type TaskResultsCallbackRelationships struct { - // Outcomes A run task result may optionally contain one or more detailed outcomes, which improves result visibility and content in the Terraform Cloud user interface. - // https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#outcomes-payload-body - Outcomes *TaskResultsCallbackRelationshipsOutcomes `json:"outcomes"` -} - -type TaskResultsCallbackRelationshipsOutcomes struct { - Data []*TaskResultsCallbackRelationshipsOutcomesData `json:"data"` -} - -type TaskResultsCallbackRelationshipsOutcomesData struct { - Type string `json:"type"` - Attributes *TaskResultsCallbackRelationshipsOutcomesDataAttributes `json:"attributes"` -} - -type TaskResultsCallbackRelationshipsOutcomesDataAttributes struct { - OutcomeID string `json:"outcome-id"` - Description string `json:"description"` - Body string `json:"body,omitempty"` - URL string `json:"url,omitempty"` - Tags map[string][]*TaskResultsCallbackRelationshipsOutcomesDataTagsAttributes `json:"tags,omitempty"` -} - -// TaskResultsCallbackRelationshipsOutcomesDataTagsAttributes can be used to enrich outcomes display list in TFC/E. +// 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 TaskResultsCallbackRelationshipsOutcomesDataTagsAttributes struct { - Label string `json:"label"` - Level string `json:"level,omitempty"` +type TaskResultTag struct { + Label string `json:"label"` + Level *string `json:"level,omitempty"` } -func (o *TaskResultsCallbackOptions) valid() error { - if o.Data == nil { - return ErrRequiredCallbackData - } - if validStringID(o.Data.Type) && o.Data.Type != String(TaskResultsCallbackType) { +func (o *TaskResultCallbackRequestOptions) valid() error { + if !validStringID(&o.Type) || o.Type != TaskResultsCallbackType { return ErrInvalidTaskResultsCallbackType } - if o.Data.Attributes == nil { - return ErrRequiredCallbackDataAttributes - } - if o.Data.Attributes.Status != TaskFailed || o.Data.Attributes.Status != TaskPassed || o.Data.Attributes.Status != TaskRunning { + if !validStringID(String(string(o.Status))) || (o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning) { return ErrInvalidTaskResultsCallbackStatus } return nil From 5b9e41c4403dd430657a3833d912d7e8a4c8cda9 Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Wed, 3 Apr 2024 15:35:56 +0300 Subject: [PATCH 04/14] chore: endpoint validation uses access token verification --- run_task_request.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/run_task_request.go b/run_task_request.go index 227de7188..84280a7d9 100644 --- a/run_task_request.go +++ b/run_task_request.go @@ -8,8 +8,8 @@ import ( ) const ( - // RunTaskRegistrationRunId is the string that TFC/E sends when it just wants to register the Run Task. - RunTaskRegistrationRunId = "run-xxxxxxxxxxxxxxxx" + // VerificationToken is a nonsense Terraform Cloud API token that should NEVER be valid. + VerificationToken = "test-token" ) // RunTaskRequest is the payload object that TFC/E sends to the Run Task's URL. @@ -48,6 +48,7 @@ type RunTaskRequestCapabilitites struct { } // IsEndpointValidation returns true if this is a Request from TFC/E to validate and register this API endpoint. +// Function copied from: https://github.com/hashicorp/terraform-run-task-scaffolding-go/blob/d7ed63b7d8eacf0897ab687d35d353386e4bd0ac/internal/sdk/api/structs.go#L55-L60 func (r RunTaskRequest) IsEndpointValidation() bool { - return r.RunID == RunTaskRegistrationRunId + return r.AccessToken == VerificationToken } From d9e1f41ee118e5a23bb04cb7c29c0f1ea15edc2b Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Thu, 4 Apr 2024 08:30:34 +0300 Subject: [PATCH 05/14] chore(task-results-options): validate options in Update function --- run_task_results_callback.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/run_task_results_callback.go b/run_task_results_callback.go index 1b6044484..d24b8d27d 100644 --- a/run_task_results_callback.go +++ b/run_task_results_callback.go @@ -22,10 +22,6 @@ type taskResultsCallback struct { client *Client } -const ( - TaskResultsCallbackType = "task-results" -) - // Update sends updates to TFC/E Run Task Callback URL func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error { if !validString(&callbackURL) { @@ -34,6 +30,9 @@ func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, ac 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 @@ -73,9 +72,6 @@ type TaskResultTag struct { } func (o *TaskResultCallbackRequestOptions) valid() error { - if !validStringID(&o.Type) || o.Type != TaskResultsCallbackType { - return ErrInvalidTaskResultsCallbackType - } if !validStringID(String(string(o.Status))) || (o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning) { return ErrInvalidTaskResultsCallbackStatus } From 7110deddd75435c1bccc6438b11603b2ddcd4bf2 Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Mon, 17 Jun 2024 17:31:37 +0300 Subject: [PATCH 06/14] chore: move TaskResultCallbackRequestOptions above Update function --- run_task_results_callback.go | 42 ++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/run_task_results_callback.go b/run_task_results_callback.go index d24b8d27d..635218778 100644 --- a/run_task_results_callback.go +++ b/run_task_results_callback.go @@ -22,27 +22,6 @@ type taskResultsCallback struct { client *Client } -// Update sends updates to TFC/E Run Task Callback URL -func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, 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) -} - // 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 { @@ -71,6 +50,27 @@ type TaskResultTag struct { Level *string `json:"level,omitempty"` } +// Update sends updates to TFC/E Run Task Callback URL +func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, 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 !validStringID(String(string(o.Status))) || (o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning) { return ErrInvalidTaskResultsCallbackStatus From a29db9a9de43a53ec7d43995c868942e178384ca Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Mon, 17 Jun 2024 17:53:12 +0300 Subject: [PATCH 07/14] chore: add unit tests for TaskResultCallbackRequestOptions --- run_task_results_callback_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 run_task_results_callback_test.go diff --git a/run_task_results_callback_test.go b/run_task_results_callback_test.go new file mode 100644 index 000000000..fb3da977b --- /dev/null +++ b/run_task_results_callback_test.go @@ -0,0 +1,28 @@ +package tfe + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestTaskResultsCallbackRequestOptions(t *testing.T) { + t.Run("with an empty status", func(t *testing.T) { + o := TaskResultCallbackRequestOptions{Status: ""} + err := o.valid() + assert.EqualError(t, err, ErrInvalidTaskResultsCallbackStatus.Error()) + }) + t.Run("without a valid Status", func(t *testing.T) { + for _, s := range []TaskResultStatus{TaskPending, TaskErrored, "foo"} { + o := TaskResultCallbackRequestOptions{Status: s} + err := o.valid() + assert.EqualError(t, err, ErrInvalidTaskResultsCallbackStatus.Error()) + } + }) + t.Run("with a valid Status option", func(t *testing.T) { + for _, s := range []TaskResultStatus{TaskFailed, TaskPassed, TaskRunning} { + o := TaskResultCallbackRequestOptions{Status: s} + err := o.valid() + assert.Nil(t, err) + } + }) +} From 8acbcede9cae2af0fde8124ce3d4a86c6392f9dc Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Tue, 18 Jun 2024 17:42:52 +0300 Subject: [PATCH 08/14] chore: add unit test TestTaskResultsCallbackRequestOptions_Marshal and TestTaskResultsCallbackUpdate --- errors.go | 2 - run_task_results_callback.go | 4 +- run_task_results_callback_test.go | 69 ++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/errors.go b/errors.go index f34517666..3a5992eed 100644 --- a/errors.go +++ b/errors.go @@ -225,8 +225,6 @@ var ( ErrInvalidAccessToken = errors.New("invalid value for access token") - ErrInvalidTaskResultsCallbackType = errors.New("invalid value for task result type") - ErrInvalidTaskResultsCallbackStatus = errors.New(fmt.Sprintf("invalid value for task result status. Must be either `%s`, `%s`, or `%s`", TaskFailed, TaskPassed, TaskRunning)) ) diff --git a/run_task_results_callback.go b/run_task_results_callback.go index 635218778..b859ab287 100644 --- a/run_task_results_callback.go +++ b/run_task_results_callback.go @@ -46,8 +46,8 @@ type TaskResultOutcome struct { // 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"` + Label string `json:"label"` + Level string `json:"level,omitempty"` } // Update sends updates to TFC/E Run Task Callback URL diff --git a/run_task_results_callback_test.go b/run_task_results_callback_test.go index fb3da977b..9e1fd9761 100644 --- a/run_task_results_callback_test.go +++ b/run_task_results_callback_test.go @@ -1,28 +1,77 @@ package tfe import ( + "bytes" + "context" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" ) -func TestTaskResultsCallbackRequestOptions(t *testing.T) { +// TestTaskResultsCallbackRequestOptions_Validate runs a series of tests that test whether various TaskResultCallbackRequestOptions objects can be considered valid or not +func TestTaskResultsCallbackRequestOptions_Validate(t *testing.T) { t.Run("with an empty status", func(t *testing.T) { - o := TaskResultCallbackRequestOptions{Status: ""} - err := o.valid() + opts := TaskResultCallbackRequestOptions{Status: ""} + err := opts.valid() assert.EqualError(t, err, ErrInvalidTaskResultsCallbackStatus.Error()) }) - t.Run("without a valid Status", func(t *testing.T) { + t.Run("without valid Status options", func(t *testing.T) { for _, s := range []TaskResultStatus{TaskPending, TaskErrored, "foo"} { - o := TaskResultCallbackRequestOptions{Status: s} - err := o.valid() + opts := TaskResultCallbackRequestOptions{Status: s} + err := opts.valid() assert.EqualError(t, err, ErrInvalidTaskResultsCallbackStatus.Error()) } }) - t.Run("with a valid Status option", func(t *testing.T) { + t.Run("with valid Status options", func(t *testing.T) { for _, s := range []TaskResultStatus{TaskFailed, TaskPassed, TaskRunning} { - o := TaskResultCallbackRequestOptions{Status: s} - err := o.valid() - assert.Nil(t, err) + 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 TestTaskResultsCallbackUpdate(t *testing.T) { + t.Run("with invalid callbackURL", func(t *testing.T) { + trc := taskResultsCallback{client: nil} + err := trc.Update(context.Background(), "", "", TaskResultCallbackRequestOptions{}) + assert.EqualError(t, err, ErrInvalidCallbackURL.Error()) + }) + t.Run("with invalid accessToken", func(t *testing.T) { + trc := taskResultsCallback{client: nil} + err := trc.Update(context.Background(), "https://app.terraform.io/foo", "", TaskResultCallbackRequestOptions{}) + assert.EqualError(t, err, ErrInvalidAccessToken.Error()) + }) +} From 57a3f09280cde79c9f38df1e137fa0fab497b117 Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Tue, 18 Jun 2024 18:39:51 +0300 Subject: [PATCH 09/14] chore: add test for taskResultsCallback Update function --- helper_test.go | 23 +++++++++++++++++++++++ run_task_results_callback_test.go | 23 ++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) 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_results_callback_test.go b/run_task_results_callback_test.go index 9e1fd9761..5bb11d139 100644 --- a/run_task_results_callback_test.go +++ b/run_task_results_callback_test.go @@ -63,7 +63,7 @@ func TestTaskResultsCallbackRequestOptions_Marshal(t *testing.T) { assert.Equal(t, reqBody.(*bytes.Buffer).String(), expectedBody) } -func TestTaskResultsCallbackUpdate(t *testing.T) { +func TestTaskResultsCallbackUpdate_Validate(t *testing.T) { t.Run("with invalid callbackURL", func(t *testing.T) { trc := taskResultsCallback{client: nil} err := trc.Update(context.Background(), "", "", TaskResultCallbackRequestOptions{}) @@ -75,3 +75,24 @@ func TestTaskResultsCallbackUpdate(t *testing.T) { assert.EqualError(t, err, ErrInvalidAccessToken.Error()) }) } + +func TestTaskResultsCallbackUpdate(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 := taskResultsCallback{ + client: client, + } + req := RunTaskRequest{ + AccessToken: testTaskResultCallbackToken, + TaskResultCallbackURL: ts.URL, + } + err = trc.Update(context.Background(), req.TaskResultCallbackURL, req.AccessToken, TaskResultCallbackRequestOptions{Status: TaskPassed}) + require.NoError(t, err) +} From 4e1579713fbbfea450a534ca1fe0c0e537c27520 Mon Sep 17 00:00:00 2001 From: Evangelos Karvounis Date: Tue, 25 Jun 2024 17:14:18 +0300 Subject: [PATCH 10/14] chore: improvements based on golangci-lint suggestions --- errors.go | 2 +- run_task_results_callback.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/errors.go b/errors.go index 3a5992eed..67d912d4a 100644 --- a/errors.go +++ b/errors.go @@ -225,7 +225,7 @@ var ( ErrInvalidAccessToken = errors.New("invalid value for access token") - ErrInvalidTaskResultsCallbackStatus = errors.New(fmt.Sprintf("invalid value for task result status. Must be either `%s`, `%s`, or `%s`", TaskFailed, TaskPassed, TaskRunning)) + ErrInvalidTaskResultsCallbackStatus = fmt.Errorf("invalid value for task result status. Must be either `%s`, `%s`, or `%s`", TaskFailed, TaskPassed, TaskRunning) ) var ( diff --git a/run_task_results_callback.go b/run_task_results_callback.go index b859ab287..db3e37ccf 100644 --- a/run_task_results_callback.go +++ b/run_task_results_callback.go @@ -14,7 +14,7 @@ var _ RunTasksCallback = (*taskResultsCallback)(nil) // https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration type RunTasksCallback interface { // Update sends updates to TFC/E Run Task Callback URL - Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error + Update(ctx context.Context, callbackURL, accessToken string, options TaskResultCallbackRequestOptions) error } // taskResultsCallback implements RunTasksCallback. @@ -51,7 +51,7 @@ type TaskResultTag struct { } // Update sends updates to TFC/E Run Task Callback URL -func (s *taskResultsCallback) Update(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error { +func (s *taskResultsCallback) Update(ctx context.Context, callbackURL, accessToken string, options TaskResultCallbackRequestOptions) error { if !validString(&callbackURL) { return ErrInvalidCallbackURL } From 8bfe446971f10c87bcd821aaaf390e57187ef8dc Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Wed, 3 Jul 2024 11:47:22 -0400 Subject: [PATCH 11/14] Rebased "main" onto a local branch From 514e19eea5a8e8607cef8ab52a56b0593ce3e0cf Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Wed, 3 Jul 2024 14:51:27 -0400 Subject: [PATCH 12/14] Rename interface to RunTasksIntegration --- ...ts_callback.go => run_tasks_integration.go | 16 ++++++------- ...k_test.go => run_tasks_integration_test.go | 23 ++++++++++--------- tfe.go | 4 ++-- 3 files changed, 22 insertions(+), 21 deletions(-) rename run_task_results_callback.go => run_tasks_integration.go (80%) rename run_task_results_callback_test.go => run_tasks_integration_test.go (83%) diff --git a/run_task_results_callback.go b/run_tasks_integration.go similarity index 80% rename from run_task_results_callback.go rename to run_tasks_integration.go index db3e37ccf..a2b0b9191 100644 --- a/run_task_results_callback.go +++ b/run_tasks_integration.go @@ -6,19 +6,19 @@ import ( ) // Compile-time proof of interface implementation. -var _ RunTasksCallback = (*taskResultsCallback)(nil) +var _ RunTasksIntegration = (*runTaskIntegration)(nil) -// RunTasksCallback describes all the Run Tasks Integration Callback API methods. +// 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 RunTasksCallback interface { +type RunTasksIntegration interface { // Update sends updates to TFC/E Run Task Callback URL - Update(ctx context.Context, callbackURL, accessToken string, options TaskResultCallbackRequestOptions) error + Callback(ctx context.Context, callbackURL string, accessToken string, options TaskResultCallbackRequestOptions) error } -// taskResultsCallback implements RunTasksCallback. -type taskResultsCallback struct { +// taskResultsCallback implements RunTasksIntegration. +type runTaskIntegration struct { client *Client } @@ -51,7 +51,7 @@ type TaskResultTag struct { } // Update sends updates to TFC/E Run Task Callback URL -func (s *taskResultsCallback) Update(ctx context.Context, callbackURL, accessToken string, options TaskResultCallbackRequestOptions) error { +func (s *runTaskIntegration) Callback(ctx context.Context, callbackURL, accessToken string, options TaskResultCallbackRequestOptions) error { if !validString(&callbackURL) { return ErrInvalidCallbackURL } @@ -72,7 +72,7 @@ func (s *taskResultsCallback) Update(ctx context.Context, callbackURL, accessTok } func (o *TaskResultCallbackRequestOptions) valid() error { - if !validStringID(String(string(o.Status))) || (o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning) { + if o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning { return ErrInvalidTaskResultsCallbackStatus } return nil diff --git a/run_task_results_callback_test.go b/run_tasks_integration_test.go similarity index 83% rename from run_task_results_callback_test.go rename to run_tasks_integration_test.go index 5bb11d139..d3f606067 100644 --- a/run_task_results_callback_test.go +++ b/run_tasks_integration_test.go @@ -3,13 +3,14 @@ package tfe import ( "bytes" "context" + "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) -// TestTaskResultsCallbackRequestOptions_Validate runs a series of tests that test whether various TaskResultCallbackRequestOptions objects can be considered valid or not -func TestTaskResultsCallbackRequestOptions_Validate(t *testing.T) { +// 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() @@ -63,20 +64,20 @@ func TestTaskResultsCallbackRequestOptions_Marshal(t *testing.T) { assert.Equal(t, reqBody.(*bytes.Buffer).String(), expectedBody) } -func TestTaskResultsCallbackUpdate_Validate(t *testing.T) { +func TestRunTasksIntegration_ValidateCallback(t *testing.T) { t.Run("with invalid callbackURL", func(t *testing.T) { - trc := taskResultsCallback{client: nil} - err := trc.Update(context.Background(), "", "", TaskResultCallbackRequestOptions{}) + 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 := taskResultsCallback{client: nil} - err := trc.Update(context.Background(), "https://app.terraform.io/foo", "", TaskResultCallbackRequestOptions{}) + trc := runTaskIntegration{client: nil} + err := trc.Callback(context.Background(), "https://app.terraform.io/foo", "", TaskResultCallbackRequestOptions{}) assert.EqualError(t, err, ErrInvalidAccessToken.Error()) }) } -func TestTaskResultsCallbackUpdate(t *testing.T) { +func TestRunTasksIntegration_Callback(t *testing.T) { ts := runTaskCallbackMockServer(t) defer ts.Close() @@ -86,13 +87,13 @@ func TestTaskResultsCallbackUpdate(t *testing.T) { Address: ts.URL, }) require.NoError(t, err) - trc := taskResultsCallback{ + trc := runTaskIntegration{ client: client, } req := RunTaskRequest{ AccessToken: testTaskResultCallbackToken, TaskResultCallbackURL: ts.URL, } - err = trc.Update(context.Background(), req.TaskResultCallbackURL, req.AccessToken, TaskResultCallbackRequestOptions{Status: TaskPassed}) + 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 da10e6ef2..c35705312 100644 --- a/tfe.go +++ b/tfe.go @@ -158,7 +158,7 @@ type Client struct { Runs Runs RunEvents RunEvents RunTasks RunTasks - RunTasksCallback RunTasksCallback + RunTasksIntegration RunTasksIntegration RunTriggers RunTriggers SSHKeys SSHKeys Stacks Stacks @@ -460,7 +460,7 @@ func NewClient(cfg *Config) (*Client, error) { client.Runs = &runs{client: client} client.RunEvents = &runEvents{client: client} client.RunTasks = &runTasks{client: client} - client.RunTasksCallback = &taskResultsCallback{client: client} + client.RunTasksIntegration = &runTaskIntegration{client: client} client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} client.Stacks = &stacks{client: client} From 1f5ad0c71ac6f0ecc10b1d7991c17cd315be04b5 Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Wed, 3 Jul 2024 14:51:43 -0400 Subject: [PATCH 13/14] Remove extraneous token check --- run_task_request.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/run_task_request.go b/run_task_request.go index 84280a7d9..b903f9486 100644 --- a/run_task_request.go +++ b/run_task_request.go @@ -7,11 +7,6 @@ import ( "time" ) -const ( - // VerificationToken is a nonsense Terraform Cloud API token that should NEVER be valid. - VerificationToken = "test-token" -) - // 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 { @@ -46,9 +41,3 @@ type RunTaskRequest struct { type RunTaskRequestCapabilitites struct { Outcomes bool `json:"outcomes"` } - -// IsEndpointValidation returns true if this is a Request from TFC/E to validate and register this API endpoint. -// Function copied from: https://github.com/hashicorp/terraform-run-task-scaffolding-go/blob/d7ed63b7d8eacf0897ab687d35d353386e4bd0ac/internal/sdk/api/structs.go#L55-L60 -func (r RunTaskRequest) IsEndpointValidation() bool { - return r.AccessToken == VerificationToken -} From e4f527e0e7e4fe2021dcdce16f943dcdae2c8bd2 Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Wed, 3 Jul 2024 15:05:19 -0400 Subject: [PATCH 14/14] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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