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 #929

Merged
merged 26 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7c96573
feat: add support for run task results callback
karvounis-form3 Mar 12, 2024
75e6c68
chore: set Run Tasks Integration as supported
karvounis-form3 Mar 12, 2024
fb8a55e
Merge branch 'main' into main
karvounis-form3 Mar 13, 2024
12b0240
Merge branch 'main' into main
karvounis-form3 Apr 2, 2024
3d73bb1
Merge branch 'main' into main
karvounis-form3 Apr 3, 2024
8e5aad7
chore: replace json structs with jsonapi ones
karvounis-form3 Apr 3, 2024
5b9e41c
chore: endpoint validation uses access token verification
karvounis-form3 Apr 3, 2024
d935548
Merge branch 'main' of github.com:karvounis-form3/go-tfe
karvounis-form3 Apr 3, 2024
d9e1f41
chore(task-results-options): validate options in Update function
karvounis-form3 Apr 4, 2024
05ed8c6
Merge branch 'main' into main
karvounis-form3 Apr 4, 2024
4b819b6
Merge branch 'main' into main
karvounis-form3 Apr 11, 2024
9c5793a
Merge branch 'main' into main
karvounis-form3 Jun 17, 2024
7110ded
chore: move TaskResultCallbackRequestOptions above Update function
karvounis-form3 Jun 17, 2024
a29db9a
chore: add unit tests for TaskResultCallbackRequestOptions
karvounis-form3 Jun 17, 2024
8acbced
chore: add unit test TestTaskResultsCallbackRequestOptions_Marshal an…
karvounis-form3 Jun 18, 2024
57a3f09
chore: add test for taskResultsCallback Update function
karvounis-form3 Jun 18, 2024
ee91d8d
Merge branch 'main' into main
karvounis-form3 Jun 19, 2024
4e15797
chore: improvements based on golangci-lint suggestions
karvounis-form3 Jun 25, 2024
195287f
Merge branch 'main' of github.com:karvounis-form3/go-tfe
karvounis-form3 Jun 25, 2024
2161c5c
Merge branch 'main' into main
karvounis-form3 Jun 26, 2024
c94fa74
Merge branch 'main' into main
karvounis-form3 Jun 28, 2024
74351d7
Merge branch 'main' into main
karvounis-form3 Jul 3, 2024
8bfe446
Rebased "main" onto a local branch
sebasslash Jul 3, 2024
514e19e
Rename interface to RunTasksIntegration
sebasslash Jul 3, 2024
1f5ad0c
Remove extraneous token check
sebasslash Jul 3, 2024
e4f527e
Update changelog
sebasslash Jul 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading