Skip to content

Commit

Permalink
Merge pull request #929 from hashicorp/sebasslash/main
Browse files Browse the repository at this point in the history
feat: add support for run task results callback
  • Loading branch information
sebasslash committed Jul 3, 2024
2 parents 7d657ff + e4f527e commit 5b078d2
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 2 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
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 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
Expand Down
7 changes: 7 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,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 (
Expand Down
23 changes: 23 additions & 0 deletions helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"io"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
Expand All @@ -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

Expand Down Expand Up @@ -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-----
Expand Down
43 changes: 43 additions & 0 deletions run_task_request.go
Original file line number Diff line number Diff line change
@@ -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"`
}
79 changes: 79 additions & 0 deletions run_tasks_integration.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 _ 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
}
99 changes: 99 additions & 0 deletions run_tasks_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
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
RunTasksIntegration RunTasksIntegration
RunTriggers RunTriggers
SSHKeys SSHKeys
Stacks Stacks
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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()))
Expand Down

0 comments on commit 5b078d2

Please sign in to comment.