Skip to content
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

feat: add support for run task results callback #867

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package tfe

import (
"errors"
"fmt"
)

// Generic errors applicable to all resources.
Expand Down Expand Up @@ -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 (
Expand Down
54 changes: 54 additions & 0 deletions run_task_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tfe

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 {
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.
// 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
}
79 changes: 79 additions & 0 deletions run_task_results_callback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 TaskResultCallbackRequestOptions) error
}

// taskResultsCallback implements RunTasksCallback.
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 {
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"`
}
Comment on lines +46 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move these above the Update() call for visibility. The style ordering for a file is:

  1. Interface definition
  2. Struct definitions
  3. Receiver method implementations.


// 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"`
}

func (o *TaskResultCallbackRequestOptions) valid() error {
if !validStringID(String(string(o.Status))) || (o.Status != TaskFailed && o.Status != TaskPassed && o.Status != TaskRunning) {
return ErrInvalidTaskResultsCallbackStatus
}
return nil
}
4 changes: 3 additions & 1 deletion tfe.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ type Client struct {
Runs Runs
RunEvents RunEvents
RunTasks RunTasks
RunTasksCallback RunTasksCallback
RunTriggers RunTriggers
SSHKeys SSHKeys
StateVersionOutputs StateVersionOutputs
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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()))
Expand Down