diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..404211a73 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "go.buildFlags": [ + "-tags=integration" + ], + "go.testTags": "integration", +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..910bc3fa7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,270 @@ +# Contributing to go-tfe + +If you find an issue with this package, please create an issue in GitHub. If you'd like, we welcome any contributions. Fork this repository and submit a pull request. + +## Writing Tests + +The test suite contains many acceptance tests that are run against the latest version of Terraform Enterprise. You can read more about running the tests against your own Terraform Enterprise environment in [TESTS.md](TESTS.md). Our CI system (Circle) will not test your fork unless you are an authorized employee, so a HashiCorp maintainer will initiate the tests or you and report any missing tests or simple problems. In order to speed up this process, it's not uncommon for your commits to be incorportated into another PR that we can commit test changes to. + +## Editor Settings + +We've included VSCode settings to assist with configuring the go extension. For other editors that integrate with the [Go Language Server](https://github.com/golang/tools/tree/master/gopls), the main thing to do is to add the `integration` build tags so that the test files are found by the language server. See `.vscode/settings.json` for more details. + +## Adding a new Endpoint + +Here you will find a scaffold to get you started when building a json:api RESTful endpoint. The comments are meant to guide you but should be replaced with endpoint-specific and type-specific documentation. Additionally, you'll need to add an integration test that covers each method of the main interface. + +In general, an interface should cover one RESTful resource, which sometimes involves two or more endpoints. Add all new modules to the tfe package. + +```go +package tfe + +import ( + "context" + "errors" + "fmt" + "net/url" +) + +var ErrInvalidExampleID = errors.New("invalid value for example ID") // move this line to errors.go + +// Compile-time proof of interface implementation +var _ ExampleResource = (*example)(nil) + +// Example represents all the example methods in the context of an organization +// that the Terraform Cloud/Enterprise API supports. +// If this API is in beta or pre-release state, include that warning here. +type ExampleResource interface { + // Create an example for an organization + Create(ctx context.Context, organization string, options ExampleCreateOptions) (*Example, error) + + // List all examples for an organization + List(ctx context.Context, organization string, options *ExampleListOptions) (*ExampleList, error) + + // Read an organization's example by ID + Read(ctx context.Context, exampleID string) (*Example, error) + + // Read an organization's example by ID with given options + ReadWithOptions(ctx context.Context, exampleID string, options *ExampleReadOptions) (*Example, error) + + // Update an example for an organization + Update(ctx context.Context, exampleID string, options ExampleUpdateOptions) (*Example, error) + + // Delete an organization's example + Delete(ctx context.Context, exampleID string) error +} + +// example implements Example +type example struct { + client *Client +} + +// Example represents a TFC/E example resource +type Example struct { + ID string `jsonapi:"primary,tasks"` + Name string `jsonapi:"attr,name"` + URL string `jsonapi:"attr,url"` + OptionalValue *string `jsonapi:"attr,optional-value,omitempty"` + + Organization *Organization `jsonapi:"relation,organization"` +} + +// ExampleList represents a list of examples +type ExampleList struct { + *Pagination + Items []*Example +} + +// ExampleCreateOptions represents the set of options for creating an example +type ExampleCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,tasks"` + + // Required: The name of the example + Name string `jsonapi:"attr,name"` + + // Required: The URL to send in the example + URL string `jsonapi:"attr,url"` + + // Optional: An optional value that is omitted if empty + OptionalValue *string `jsonapi:"attr,optional-value,omitempty"` +} + +func (o *ExampleCreateOptions) valid() error { + if !validString(&o.Name) { + return ErrRequiredName + } + + if !validString(&o.URL) { + return ErrInvalidRunTaskURL + } + + return nil +} + +// Create is used to create a new example for an organization +func (s *example) Create(ctx context.Context, organization string, options ExampleCreateOptions) (*Example, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("organizations/%s/tasks", url.QueryEscape(organization)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + r := &Example{} + err = s.client.do(ctx, req, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// A list of relations to include with an example. See available resources: +// https://www.terraform.io/cloud-docs/api-docs/examples#list-examples (replace this URL with the actual documentation URL) +type ExampleIncludeOps string + +const ( + ExampleOrganization ExampleIncludeOps = "organization" +) + +// ExampleListOptions represents the set of options for listing examples +type ExampleListOptions struct { + ListOptions + + // A list of relations to include + Include []ExampleIncludeOps `url:"include,omitempty"` +} + +// List all the examples for an organization +func (s *example) List(ctx context.Context, organization string, options *ExampleListOptions) (*ExampleList, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + u := fmt.Sprintf("organizations/%s/examples", url.QueryEscape(organization)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + el := &ExampleList{} + err = s.client.do(ctx, req, el) + if err != nil { + return nil, err + } + + return el, nil +} + +// Read is used to read an organization's example by ID +func (s *example) Read(ctx context.Context, exampleID string) (*Example, error) { + return s.ReadWithOptions(ctx, exampleID, nil) +} + +// ExampleReadOptions represents the set of options for reading an example +type ExampleReadOptions struct { + Include []RunTaskIncludeOps `url:"include,omitempty"` +} + +// Read is used to read an organization's example by ID with options +func (s *example) ReadWithOptions(ctx context.Context, exampleID string, options *ExampleReadOptions) (*Example, error) { + if !validStringID(&exampleID) { + return nil, ErrInvalidExampleID + } + + u := fmt.Sprintf("examples/%s", url.QueryEscape(exampleID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + e := &Example{} + err = s.client.do(ctx, req, e) + if err != nil { + return nil, err + } + + return e, nil +} + +// ExampleUpdateOptions represents the set of options for updating an organization's examples +type ExampleUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,tasks"` + + // Optional: The name of the example, defaults to previous value + Name *string `jsonapi:"attr,name,omitempty"` + + // Optional: The URL to send a example payload, defaults to previous value + URL *string `jsonapi:"attr,url,omitempty"` + + // Optional: An optional value + OptionalValue *string `jsonapi:"attr,optional-value,omitempty"` +} + +func (o *ExampleUpdateOptions) valid() error { + if o.Name != nil && !validString(o.Name) { + return ErrRequiredName + } + + if o.URL != nil && !validString(o.URL) { + return ErrInvalidRunTaskURL + } + + return nil +} + +// Update an existing example for an organization by ID +func (s *example) Update(ctx context.Context, exampleID string, options ExampleUpdateOptions) (*Example, error) { + if !validStringID(&exampleID) { + return nil, ErrInvalidExampleID + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("examples/%s", url.QueryEscape(exampleID)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + r := &Example{} + err = s.client.do(ctx, req, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// Delete an existing example for an organization by ID +func (s *example) Delete(ctx context.Context, exampleID string) error { + if !validStringID(&exampleID) { + return ErrInvalidExampleID + } + + u := fmt.Sprintf("examples/%s", exampleID) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} +``` diff --git a/README.md b/README.md index 6dcafeb56..0d336a2d4 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,11 @@ See the [examples directory](https://github.com/hashicorp/go-tfe/tree/main/examp ## Running tests -See [TESTS.md](https://github.com/hashicorp/go-tfe/tree/main/TESTS.md). +See [TESTS.md](TESTS.md). ## Issues and Contributing -If you find an issue with this package, please report an issue. If you'd like, -we welcome any contributions. Fork this repository and submit a pull request. +See [CONTRIBUTING.md](CONTRIBUTING.md) ## Releases @@ -78,12 +77,12 @@ Documentation updates and test fixes that only touch test files don't require a ### Creating a release 1. [Create a new release in GitHub](https://help.github.com/en/github/administering-a-repository/creating-releases) by clicking on "Releases" and then "Draft a new release" -1. Set the `Tag version` to a new tag, using [Semantic Versioning](https://semver.org/) as a guideline. +1. Set the `Tag version` to a new tag, using [Semantic Versioning](https://semver.org/) as a guideline. 1. Set the `Target` as `main`. 1. Set the `Release title` to the tag you created, `vX.Y.Z` 1. Use the description section to describe why you're releasing and what changes you've made. You should include links to merged PRs. Use the following headers in the description of your release: - BREAKING CHANGES: Use this for any changes that aren't backwards compatible. Include details on how to handle these changes. - - FEATURES: Use this for any large new features added, + - FEATURES: Use this for any large new features added, - ENHANCEMENTS: Use this for smaller new features added - BUG FIXES: Use this for any bugs that were fixed. - NOTES: Use this section if you need to include any additional notes on things like upgrading, upcoming deprecations, or any other information you might want to highlight. diff --git a/TESTS.md b/TESTS.md index 8f171d6df..6e2a327d8 100644 --- a/TESTS.md +++ b/TESTS.md @@ -27,6 +27,8 @@ Tests are run against an actual backend so they require a valid backend address 1. `GITHUB_REGISTRY_MODULE_IDENTIFIER` - GitHub registry module repository identifier in the format `username/repository`. Required for running registry module tests. 1. `ENABLE_TFE` - Some tests are only applicable to Terraform Enterprise or Terraform Cloud. By setting `ENABLE_TFE=1` you will enable enterprise only tests and disable cloud only tests. In CI `ENABLE_TFE` is not set so if you are writing enterprise only features you should manually test with `ENABLE_TFE=1` against a Terraform Enterprise instance. 1. `SKIP_PAID` - Some tests depend on paid only features. By setting `SKIP_PAID=1`, you will skip tests that access paid features. +1. `ENABLE_BETA` - Some tests require access to beta features. By setting `ENABLE_BETA=1` you will enable tests that require access to beta features. IN CI `ENABLE_BETA` is not set so if you are writing beta only features you should manually test with `ENABLE_BETA=1` against a Terraform Enterprise instance with those features enabled. +1. `TFC_RUN_TASK_URL` - Run task integration tests require a URL to use when creating run tasks. To learn more about the Run Task API, [read here](https://www.terraform.io/cloud-docs/api-docs/run-tasks) You can set your environment variables up however you prefer. The following are instructions for setting up environment variables using [envchain](https://github.com/sorah/envchain). 1. Make sure you have envchain installed. [Instructions for this can be found in the envchain README](https://github.com/sorah/envchain#installation). diff --git a/errors.go b/errors.go index d9b157312..369301599 100644 --- a/errors.go +++ b/errors.go @@ -72,6 +72,20 @@ var ( ErrInvalidRunID = errors.New("invalid value for run ID") + ErrInvalidRunTaskCategory = errors.New(`category must be "task"`) + + ErrInvalidRunTaskID = errors.New("invalid value for run task ID") + + ErrInvalidRunTaskURL = errors.New("invalid url for run task URL") + + ErrInvalidWorkspaceRunTaskID = errors.New("invalid value for workspace run task ID") + + ErrInvalidWorkspaceRunTaskType = errors.New(`invalid value for type, please use "workspace-tasks"`) + + ErrInvalidTaskResultID = errors.New("invalid value for task result ID") + + ErrInvalidTaskStageID = errors.New("invalid value for task stage ID") + ErrInvalidApplyID = errors.New("invalid value for apply ID") ErrInvalidOrg = errors.New("invalid value for organization") diff --git a/helper_test.go b/helper_test.go index f80e13fa4..f2645d37c 100644 --- a/helper_test.go +++ b/helper_test.go @@ -702,6 +702,41 @@ func createRegistryModuleWithVersion(t *testing.T, client *Client, org *Organiza } } +func createRunTask(t *testing.T, client *Client, org *Organization) (*RunTask, func()) { + var orgCleanup func() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + runTaskURL := os.Getenv("TFC_RUN_TASK_URL") + if runTaskURL == "" { + t.Error("Cannot create a run task with an empty URL. You must set TFC_RUN_TASK_URL for run task related tests.") + } + + ctx := context.Background() + r, err := client.RunTasks.Create(ctx, org.Name, RunTaskCreateOptions{ + Name: "tst-" + randomString(t), + URL: runTaskURL, + Category: "task", + }) + if err != nil { + t.Fatal(err) + } + + return r, func() { + if err := client.RunTasks.Delete(ctx, r.ID); err != nil { + t.Errorf("Error removing Run Task! WARNING: Run task limit\n"+ + "may be reached if not deleted! The full error is shown below.\n\n"+ + "Run Task: %s\nError: %s", r.Name, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + func createSSHKey(t *testing.T, client *Client, org *Organization) (*SSHKey, func()) { var orgCleanup func() @@ -991,6 +1026,52 @@ func createWorkspaceWithVCS(t *testing.T, client *Client, org *Organization, opt } } +func createWorkspaceRunTask(t *testing.T, client *Client, workspace *Workspace, runTask *RunTask) (*WorkspaceRunTask, func()) { + var organization *Organization + var runTaskCleanup func() + var workspaceCleanup func() + var orgCleanup func() + + if workspace == nil { + organization, orgCleanup = createOrganization(t, client) + workspace, workspaceCleanup = createWorkspace(t, client, organization) + } + + if runTask == nil { + runTask, runTaskCleanup = createRunTask(t, client, organization) + } + + ctx := context.Background() + wr, err := client.WorkspaceRunTasks.Create(ctx, workspace.ID, WorkspaceRunTaskCreateOptions{ + EnforcementLevel: Advisory, + RunTask: runTask, + }) + if err != nil { + t.Fatal(err) + } + + return wr, func() { + if err := client.WorkspaceRunTasks.Delete(ctx, workspace.ID, wr.ID); err != nil { + t.Errorf("Error destroying workspace run task!\n"+ + "Workspace: %s\n"+ + "Workspace Run Task: %s\n"+ + "Error: %s", workspace.ID, wr.ID, err) + } + + if runTaskCleanup != nil { + runTaskCleanup() + } + + if workspaceCleanup != nil { + workspaceCleanup() + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + func genSha(t *testing.T, secret, data string) string { h := hmac.New(sha256.New, []byte(secret)) _, err := h.Write([]byte(data)) @@ -1031,6 +1112,13 @@ func skipIfFreeOnly(t *testing.T) { } } +// skips a test if the test requires a beta feature +func skipIfBeta(t *testing.T) { + if !betaFeaturesEnabled() { + t.Skip("Skipping test related to a Terraform Cloud beta feature. Set ENABLE_BETA=1 to run.") + } +} + // Checks to see if ENABLE_TFE is set to 1, thereby enabling enterprise tests. func enterpriseEnabled() bool { return os.Getenv("ENABLE_TFE") == "1" @@ -1039,3 +1127,8 @@ func enterpriseEnabled() bool { func paidFeaturesDisabled() bool { return os.Getenv("SKIP_PAID") == "1" } + +// Checks to see if ENABLE_BETA is set to 1, thereby enabling tests for beta features. +func betaFeaturesEnabled() bool { + return os.Getenv("ENABLE_BETA") == "1" +} diff --git a/run.go b/run.go index 8fc65fca0..28b59e402 100644 --- a/run.go +++ b/run.go @@ -68,6 +68,8 @@ const ( RunPolicyChecking RunStatus = "policy_checking" RunPolicyOverride RunStatus = "policy_override" RunPolicySoftFailed RunStatus = "policy_soft_failed" + RunPostPlanRunning RunStatus = "post_plan_running" + RunPostPlanCompleted RunStatus = "post_plan_completed" ) // RunSource represents a source type of a run. @@ -114,6 +116,7 @@ type Run struct { CreatedBy *User `jsonapi:"relation,created-by"` Plan *Plan `jsonapi:"relation,plan"` PolicyChecks []*PolicyCheck `jsonapi:"relation,policy-checks"` + TaskStages []*TaskStage `jsonapi:"relation,task-stages,omitempty"` Workspace *Workspace `jsonapi:"relation,workspace"` } diff --git a/run_task.go b/run_task.go new file mode 100644 index 000000000..0f764aace --- /dev/null +++ b/run_task.go @@ -0,0 +1,275 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation +var _ RunTasks = (*runTasks)(nil) + +// RunTasks represents all the run task related methods in the context of an organization +// that the Terraform Cloud/Enterprise API supports. +// **Note: This API is still in BETA and subject to change.** +type RunTasks interface { + // Create a run task for an organization + Create(ctx context.Context, organization string, options RunTaskCreateOptions) (*RunTask, error) + + // List all run tasks for an organization + List(ctx context.Context, organization string, options *RunTaskListOptions) (*RunTaskList, error) + + // Read an organization's run task by ID + Read(ctx context.Context, runTaskID string) (*RunTask, error) + + // Read an organization's run task by ID with given options + ReadWithOptions(ctx context.Context, runTaskID string, options *RunTaskReadOptions) (*RunTask, error) + + // Update a run task for an organization + Update(ctx context.Context, runTaskID string, options RunTaskUpdateOptions) (*RunTask, error) + + // Delete an organization's run task + Delete(ctx context.Context, runTaskID string) error + + // Attach a run task to an organization's workspace + AttachToWorkspace(ctx context.Context, workspaceID string, runTaskID string, enforcementLevel TaskEnforcementLevel) (*WorkspaceRunTask, error) +} + +// runTasks implements RunTasks +type runTasks struct { + client *Client +} + +// RunTask represents a TFC/E run task +type RunTask struct { + ID string `jsonapi:"primary,tasks"` + Name string `jsonapi:"attr,name"` + URL string `jsonapi:"attr,url"` + Category string `jsonapi:"attr,category"` + HMACKey *string `jsonapi:"attr,hmac-key,omitempty"` + + Organization *Organization `jsonapi:"relation,organization"` + WorkspaceRunTasks []*WorkspaceRunTask `jsonapi:"relation,workspace-tasks"` +} + +// RunTaskList represents a list of run tasks +type RunTaskList struct { + *Pagination + Items []*RunTask +} + +// RunTaskCreateOptions represents the set of options for creating a run task +type RunTaskCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,tasks"` + + // Required: The name of the run task + Name string `jsonapi:"attr,name"` + + // Required: The URL to send a run task payload + URL string `jsonapi:"attr,url"` + + // Required: Must be "task" + Category string `jsonapi:"attr,category"` + + // Optional: An HMAC key to verify the run task + HMACKey *string `jsonapi:"attr,hmac-key,omitempty"` +} + +func (o *RunTaskCreateOptions) valid() error { + if !validString(&o.Name) { + return ErrRequiredName + } + + if !validString(&o.URL) { + return ErrInvalidRunTaskURL + } + + if o.Category != "task" { + return ErrInvalidRunTaskCategory + } + + return nil +} + +// Create is used to create a new run task for an organization +func (s *runTasks) Create(ctx context.Context, organization string, options RunTaskCreateOptions) (*RunTask, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("organizations/%s/tasks", url.QueryEscape(organization)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + r := &RunTask{} + err = s.client.do(ctx, req, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// A list of relations to include with a run task. See available resources: +// https://www.terraform.io/cloud-docs/api-docs/run-tasks#list-run-tasks +type RunTaskIncludeOps string + +const ( + RunTaskWorkspaceTasks RunTaskIncludeOps = "workspace_tasks" + RunTaskWorkspace RunTaskIncludeOps = "workspace_tasks.workspace" +) + +// RunTaskListOptions represents the set of options for listing run tasks +type RunTaskListOptions struct { + ListOptions + + // A list of relations to include + Include []RunTaskIncludeOps `url:"include"` +} + +// List all the run tasks for an organization +func (s *runTasks) List(ctx context.Context, organization string, options *RunTaskListOptions) (*RunTaskList, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + u := fmt.Sprintf("organizations/%s/tasks", url.QueryEscape(organization)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + rl := &RunTaskList{} + err = s.client.do(ctx, req, rl) + if err != nil { + return nil, err + } + + return rl, nil +} + +// Read is used to read an organization's run task by ID +func (s *runTasks) Read(ctx context.Context, runTaskID string) (*RunTask, error) { + return s.ReadWithOptions(ctx, runTaskID, nil) +} + +// RunTaskReadOptions represents the set of options for reading a run task +type RunTaskReadOptions struct { + Include []RunTaskIncludeOps `url:"include"` +} + +// Read is used to read an organization's run task by ID with options +func (s *runTasks) ReadWithOptions(ctx context.Context, runTaskID string, options *RunTaskReadOptions) (*RunTask, error) { + if !validStringID(&runTaskID) { + return nil, ErrInvalidRunTaskID + } + + u := fmt.Sprintf("tasks/%s", url.QueryEscape(runTaskID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + r := &RunTask{} + err = s.client.do(ctx, req, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// RunTaskUpdateOptions represents the set of options for updating an organization's run task +type RunTaskUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,tasks"` + + // Optional: The name of the run task, defaults to previous value + Name *string `jsonapi:"attr,name,omitempty"` + + // Optional: The URL to send a run task payload, defaults to previous value + URL *string `jsonapi:"attr,url,omitempty"` + + // Optional: Must be "task", defaults to "task" + Category *string `jsonapi:"attr,category,omitempty"` + + // Optional: An HMAC key to verify the run task + HMACKey *string `jsonapi:"attr,hmac-key,omitempty"` +} + +func (o *RunTaskUpdateOptions) valid() error { + if o.Name != nil && !validString(o.Name) { + return ErrRequiredName + } + + if o.URL != nil && !validString(o.URL) { + return ErrInvalidRunTaskURL + } + + if o.Category != nil && *o.Category != "task" { + return ErrInvalidRunTaskCategory + } + + return nil +} + +// Update an existing run task for an organization by ID +func (s *runTasks) Update(ctx context.Context, runTaskID string, options RunTaskUpdateOptions) (*RunTask, error) { + if !validStringID(&runTaskID) { + return nil, ErrInvalidRunTaskID + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("tasks/%s", url.QueryEscape(runTaskID)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + r := &RunTask{} + err = s.client.do(ctx, req, r) + if err != nil { + return nil, err + } + + return r, nil +} + +// Delete an existing run task for an organization by ID +func (s *runTasks) Delete(ctx context.Context, runTaskID string) error { + if !validStringID(&runTaskID) { + return ErrInvalidRunTaskID + } + + u := fmt.Sprintf("tasks/%s", runTaskID) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} + +// Convenient method to attach a run task to a workspace. See: WorkspaceRunTasks.Create() +func (s *runTasks) AttachToWorkspace(ctx context.Context, workspaceID string, runTaskID string, enforcement TaskEnforcementLevel) (*WorkspaceRunTask, error) { + return s.client.WorkspaceRunTasks.Create(ctx, workspaceID, WorkspaceRunTaskCreateOptions{ + EnforcementLevel: enforcement, + RunTask: &RunTask{ID: runTaskID}, + }) +} diff --git a/run_task_integration_test.go b/run_task_integration_test.go new file mode 100644 index 000000000..11b1b86b6 --- /dev/null +++ b/run_task_integration_test.go @@ -0,0 +1,201 @@ +//go:build integration +// +build integration + +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunTasksCreate(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskServerURL := os.Getenv("TFC_RUN_TASK_URL") + if runTaskServerURL == "" { + t.Error("Cannot create a run task with an empty URL. You must set TFC_RUN_TASK_URL for run task related tests.") + } + + runTaskName := "tst-runtask-" + randomString(t) + + t.Run("add run task to organization", func(t *testing.T) { + r, err := client.RunTasks.Create(ctx, orgTest.Name, RunTaskCreateOptions{ + Name: runTaskName, + URL: runTaskServerURL, + Category: "task", + }) + require.NoError(t, err) + + assert.NotEmpty(t, r.ID) + assert.Equal(t, r.Name, runTaskName) + assert.Equal(t, r.URL, runTaskServerURL) + assert.Equal(t, r.Category, "task") + + t.Run("ensure org is deserialized properly", func(t *testing.T) { + assert.Equal(t, r.Organization.Name, orgTest.Name) + }) + }) +} + +func TestRunTasksList(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + _, runTaskTest1Cleanup := createRunTask(t, client, orgTest) + defer runTaskTest1Cleanup() + + _, runTaskTest2Cleanup := createRunTask(t, client, orgTest) + defer runTaskTest2Cleanup() + + t.Run("with no params", func(t *testing.T) { + runTaskList, err := client.RunTasks.List(ctx, orgTest.Name, nil) + require.NoError(t, err) + assert.NotNil(t, runTaskList.Items) + assert.NotEmpty(t, runTaskList.Items[0].ID) + assert.NotEmpty(t, runTaskList.Items[0].URL) + assert.NotEmpty(t, runTaskList.Items[1].ID) + assert.NotEmpty(t, runTaskList.Items[1].URL) + }) +} + +func TestRunTasksRead(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + t.Run("by ID", func(t *testing.T) { + r, err := client.RunTasks.Read(ctx, runTaskTest.ID) + require.NoError(t, err) + + assert.Equal(t, runTaskTest.ID, r.ID) + assert.Equal(t, runTaskTest.URL, r.URL) + assert.Equal(t, runTaskTest.Category, r.Category) + assert.Equal(t, runTaskTest.HMACKey, r.HMACKey) + }) + + t.Run("with options", func(t *testing.T) { + wkTest1, wkTest1Cleanup := createWorkspace(t, client, orgTest) + defer wkTest1Cleanup() + + wkTest2, wkTest2Cleanup := createWorkspace(t, client, orgTest) + defer wkTest2Cleanup() + + _, wrTest1Cleanup := createWorkspaceRunTask(t, client, wkTest1, runTaskTest) + defer wrTest1Cleanup() + + _, wrTest2Cleanup := createWorkspaceRunTask(t, client, wkTest2, runTaskTest) + defer wrTest2Cleanup() + + r, err := client.RunTasks.ReadWithOptions(ctx, runTaskTest.ID, &RunTaskReadOptions{ + Include: []RunTaskIncludeOps{RunTaskWorkspaceTasks}, + }) + + require.NoError(t, err) + + assert.NotEmpty(t, r.WorkspaceRunTasks) + assert.NotEmpty(t, r.WorkspaceRunTasks[0].ID) + assert.NotEmpty(t, r.WorkspaceRunTasks[0].EnforcementLevel) + assert.NotEmpty(t, r.WorkspaceRunTasks[1].ID) + assert.NotEmpty(t, r.WorkspaceRunTasks[1].EnforcementLevel) + }) +} + +func TestRunTasksUpdate(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + t.Run("rename task", func(t *testing.T) { + rename := runTaskTest.Name + "-UPDATED" + r, err := client.RunTasks.Update(ctx, runTaskTest.ID, RunTaskUpdateOptions{ + Name: &rename, + }) + require.NoError(t, err) + + r, err = client.RunTasks.Read(ctx, r.ID) + require.NoError(t, err) + + assert.Equal(t, rename, r.Name) + }) +} + +func TestRunTasksDelete(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, _ := createRunTask(t, client, orgTest) + + t.Run("with valid options", func(t *testing.T) { + err := client.RunTasks.Delete(ctx, runTaskTest.ID) + require.NoError(t, err) + + _, err = client.RunTasks.Read(ctx, runTaskTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the run task does not exist", func(t *testing.T) { + err := client.RunTasks.Delete(ctx, runTaskTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the run task ID is invalid", func(t *testing.T) { + err := client.RunTasks.Delete(ctx, badIdentifier) + assert.EqualError(t, err, ErrInvalidRunTaskID.Error()) + }) +} + +func TestRunTasksAttachToWorkspace(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + t.Run("to a valid workspace", func(t *testing.T) { + wr, err := client.RunTasks.AttachToWorkspace(ctx, wkspaceTest.ID, runTaskTest.ID, Advisory) + require.NoError(t, err) + require.NotNil(t, wr.ID) + }) +} diff --git a/task_result.go b/task_result.go new file mode 100644 index 000000000..e67e6f38a --- /dev/null +++ b/task_result.go @@ -0,0 +1,88 @@ +package tfe + +import ( + "context" + "fmt" + "time" +) + +// Compile-time proof of interface implementation +var _ TaskResults = (*taskResults)(nil) + +// TaskResults describes all the task result related methods that the TFC/E API supports. +// **Note**: This API is still in BETA and is subject to change +type TaskResults interface { + // Read a task result by ID + Read(ctx context.Context, taskResultID string) (*TaskResult, error) +} + +// taskResults implements TaskResults +type taskResults struct { + client *Client +} + +//TaskResultStatus is an enum that represents all possible statuses for a task result +type TaskResultStatus string + +//TaskEnforcementLevel is an enum that describes the enforcement levels for a run task +type TaskEnforcementLevel string + +const ( + TaskPassed TaskResultStatus = "passed" + TaskFailed TaskResultStatus = "failed" + TaskRunning TaskResultStatus = "running" + TaskPending TaskResultStatus = "pending" + TaskUnreachable TaskResultStatus = "unreachable" + + Advisory TaskEnforcementLevel = "advisory" + Mandatory TaskEnforcementLevel = "mandatory" +) + +// TaskResultStatusTimestamps represents the set of timestamps recorded for a task result +type TaskResultStatusTimestamps struct { + ErroredAt time.Time `jsonapi:"attr,errored-at,rfc3339"` + RunningAt time.Time `jsonapi:"attr,running-at,rfc3339"` + CanceledAt time.Time `jsonapi:"attr,canceled-at,rfc3339"` + FailedAt time.Time `jsonapi:"attr,failed-at,rfc3339"` + PassedAt time.Time `jsonapi:"attr,passed-at,rfc3339"` +} + +// TaskResult represents the result of a TFC/E run task +type TaskResult struct { + ID string `jsonapi:"primary,task-results"` + Status TaskResultStatus `jsonapi:"attr,status"` + Message string `jsonapi:"attr,message"` + StatusTimestamps TaskResultStatusTimestamps `jsonapi:"attr,status-timestamps"` + URL string `jsonapi:"attr,url"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` + TaskID string `jsonapi:"attr,task-id"` + TaskName string `jsonapi:"attr,task-name"` + TaskURL string `jsonapi:"attr,task-url"` + WorkspaceTaskID string `jsonapi:"attr,workspace-task-id"` + WorkspaceTaskEnforcementLevel TaskEnforcementLevel `jsonapi:"attr,workspace-task-enforcement-level"` + + // The task stage this result belongs to + TaskStage *TaskStage `jsonapi:"relation,task_stage"` +} + +// Read a task result by ID +func (t *taskResults) Read(ctx context.Context, taskResultID string) (*TaskResult, error) { + if !validStringID(&taskResultID) { + return nil, ErrInvalidTaskResultID + } + + u := fmt.Sprintf("task-results/%s", taskResultID) + req, err := t.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + r := &TaskResult{} + err = t.client.do(ctx, req, r) + if err != nil { + return nil, err + } + + return r, nil +} diff --git a/task_stages.go b/task_stages.go new file mode 100644 index 000000000..4c2c124d5 --- /dev/null +++ b/task_stages.go @@ -0,0 +1,120 @@ +package tfe + +import ( + "context" + "fmt" + "time" +) + +// Compile-time proof of interface implementation +var _ TaskStages = (*taskStages)(nil) + +// TaskStages describes all the task stage related methods that the TFC/E API +// supports. +// **Note: This API is still in BETA and is subject to change.** +type TaskStages interface { + // Read a task stage by ID + Read(ctx context.Context, taskStageID string, options *TaskStageReadOptions) (*TaskStage, error) + + // List all task stages for a given rrun + List(ctx context.Context, runID string, options *TaskStageListOptions) (*TaskStageList, error) +} + +// taskStages implements TaskStages +type taskStages struct { + client *Client +} + +// Stage is an enum that represents the possible run stages for run tasks +type Stage string + +const ( + PostPlan Stage = "post_plan" +) + +// TaskStage represents a TFC/E run's stage where run tasks can occur +type TaskStage struct { + ID string `jsonapi:"primary,task-stages"` + Stage Stage `jsonapi:"attr,stage"` + StatusTimestamps TaskStageStatusTimestamps `jsonapi:"attr,status-timestamps"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` + + Run *Run `jsonapi:"relation,run"` + TaskResults []*TaskResult `jsonapi:"relation,task-results"` +} + +// TaskStageList represents a list of task stages +type TaskStageList struct { + *Pagination + Items []*TaskStage +} + +// TaskStageStatusTimestamps represents the set of timestamps recorded for a task stage +type TaskStageStatusTimestamps struct { + ErroredAt time.Time `jsonapi:"attr,errored-at,rfc3339"` + RunningAt time.Time `jsonapi:"attr,running-at,rfc3339"` + CanceledAt time.Time `jsonapi:"attr,canceled-at,rfc3339"` + FailedAt time.Time `jsonapi:"attr,failed-at,rfc3339"` + PassedAt time.Time `jsonapi:"attr,passed-at,rfc3339"` +} + +// A list of relations to include. +type TaskStageIncludeOps string + +const ( + TaskStageTaskResults TaskStageIncludeOps = "task_results" +) + +// TaskStageReadOptions represents the set of options when reading a task stage +type TaskStageReadOptions struct { + Include []TaskStageIncludeOps `url:"include"` +} + +// Read a task stage by ID +func (s *taskStages) Read(ctx context.Context, taskStageID string, options *TaskStageReadOptions) (*TaskStage, error) { + if !validStringID(&taskStageID) { + return nil, ErrInvalidTaskStageID + } + + u := fmt.Sprintf("task-stages/%s", taskStageID) + req, err := s.client.newRequest("GET", u, &options) + if err != nil { + return nil, err + } + + t := &TaskStage{} + err = s.client.do(ctx, req, t) + if err != nil { + return nil, err + } + + return t, nil +} + +// TaskStageListOptions represents the options for listing task stages for a run +type TaskStageListOptions struct { + ListOptions +} + +// List task stages for a run +func (s *taskStages) List(ctx context.Context, runID string, options *TaskStageListOptions) (*TaskStageList, error) { + if !validStringID(&runID) { + return nil, ErrInvalidRunID + } + + u := fmt.Sprintf("runs/%s/task-stages", runID) + req, err := s.client.newRequest("GET", u, &options) + if err != nil { + return nil, err + } + + tlist := &TaskStageList{} + + err = s.client.do(ctx, req, tlist) + if err != nil { + return nil, err + } + + return tlist, nil +} diff --git a/task_stages_integration_test.go b/task_stages_integration_test.go new file mode 100644 index 000000000..b59d750df --- /dev/null +++ b/task_stages_integration_test.go @@ -0,0 +1,116 @@ +//go:build integration +// +build integration + +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskStagesRead(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + wrTaskTest, wrTaskTestCleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest) + defer wrTaskTestCleanup() + + rTest, rTestCleanup := createRun(t, client, wkspaceTest) + defer rTestCleanup() + + t.Run("without include param", func(t *testing.T) { + r, err := client.Runs.ReadWithOptions(ctx, rTest.ID, &RunReadOptions{ + Include: "task_stages", + }) + require.NoError(t, err) + + taskStage, err := client.TaskStages.Read(ctx, r.TaskStages[0].ID, nil) + require.NoError(t, err) + + assert.NotEmpty(t, taskStage.ID) + assert.NotEmpty(t, taskStage.Stage) + assert.NotNil(t, taskStage.StatusTimestamps.ErroredAt) + assert.NotNil(t, taskStage.StatusTimestamps.RunningAt) + assert.NotNil(t, taskStage.CreatedAt) + assert.NotNil(t, taskStage.UpdatedAt) + assert.NotNil(t, taskStage.Run) + assert.NotNil(t, taskStage.TaskResults) + + // so this bit is interesting, if the relation is not specified in the include + // param, the fields of the struct will be zeroed out, minus the ID + assert.NotEmpty(t, taskStage.TaskResults[0].ID) + assert.Empty(t, taskStage.TaskResults[0].Status) + assert.Empty(t, taskStage.TaskResults[0].Message) + }) + + t.Run("with include param task_results", func(t *testing.T) { + r, err := client.Runs.ReadWithOptions(ctx, rTest.ID, &RunReadOptions{ + Include: "task_stages", + }) + require.NoError(t, err) + + taskStage, err := client.TaskStages.Read(ctx, r.TaskStages[0].ID, &TaskStageReadOptions{ + Include: []TaskStageIncludeOps{TaskStageTaskResults}, + }) + require.NoError(t, err) + + t.Run("task results are properly decoded", func(t *testing.T) { + assert.NotEmpty(t, taskStage.TaskResults[0].ID) + assert.NotEmpty(t, taskStage.TaskResults[0].Status) + assert.NotEmpty(t, taskStage.TaskResults[0].CreatedAt) + assert.Equal(t, wrTaskTest.ID, taskStage.TaskResults[0].WorkspaceTaskID) + assert.Equal(t, runTaskTest.Name, taskStage.TaskResults[0].TaskName) + }) + }) +} + +func TestTaskStagesList(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + runTaskTest2, runTaskTest2Cleanup := createRunTask(t, client, orgTest) + defer runTaskTest2Cleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + _, wrTaskTestCleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest) + defer wrTaskTestCleanup() + + _, wrTaskTest2Cleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest2) + defer wrTaskTest2Cleanup() + + rTest, rTestCleanup := createRun(t, client, wkspaceTest) + defer rTestCleanup() + + t.Run("with no params", func(t *testing.T) { + taskStageList, err := client.TaskStages.List(ctx, rTest.ID, nil) + require.NoError(t, err) + + assert.NotNil(t, taskStageList.Items) + assert.NotEmpty(t, taskStageList.Items[0].ID) + assert.Equal(t, 2, len(taskStageList.Items[0].TaskResults)) + }) +} diff --git a/tfe.go b/tfe.go index 6d38661c9..9a6611e60 100644 --- a/tfe.go +++ b/tfe.go @@ -28,10 +28,10 @@ import ( ) const ( - userAgent = "go-tfe" - headerRateLimit = "X-RateLimit-Limit" - headerRateReset = "X-RateLimit-Reset" - headerAPIVersion = "TFP-API-Version" + _userAgent = "go-tfe" + _headerRateLimit = "X-RateLimit-Limit" + _headerRateReset = "X-RateLimit-Reset" + _headerAPIVersion = "TFP-API-Version" // DefaultAddress of Terraform Enterprise. DefaultAddress = "https://app.terraform.io" @@ -82,7 +82,7 @@ func DefaultConfig() *Config { } // Set the default user agent. - config.Headers.Set("User-Agent", userAgent) + config.Headers.Set("User-Agent", _userAgent) return config } @@ -121,10 +121,13 @@ type Client struct { PolicySets PolicySets RegistryModules RegistryModules Runs Runs + RunTasks RunTasks RunTriggers RunTriggers SSHKeys SSHKeys StateVersionOutputs StateVersionOutputs StateVersions StateVersions + TaskResults TaskResults + TaskStages TaskStages Teams Teams TeamAccess TeamAccesses TeamMembers TeamMembers @@ -133,6 +136,7 @@ type Client struct { UserTokens UserTokens Variables Variables Workspaces Workspaces + WorkspaceRunTasks WorkspaceRunTasks Meta Meta } @@ -258,10 +262,12 @@ func NewClient(cfg *Config) (*Client, error) { client.PolicySets = &policySets{client: client} client.RegistryModules = ®istryModules{client: client} client.Runs = &runs{client: client} + client.RunTasks = &runTasks{client: client} client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} client.StateVersionOutputs = &stateVersionOutputs{client: client} client.StateVersions = &stateVersions{client: client} + client.TaskStages = &taskStages{client: client} client.Teams = &teams{client: client} client.TeamAccess = &teamAccesses{client: client} client.TeamMembers = &teamMembers{client: client} @@ -270,6 +276,7 @@ func NewClient(cfg *Config) (*Client, error) { client.UserTokens = &userTokens{client: client} client.Variables = &variables{client: client} client.Workspaces = &workspaces{client: client} + client.WorkspaceRunTasks = &workspaceRunTasks{client: client} client.Meta = Meta{ IPRanges: &ipRanges{client: client}, @@ -361,8 +368,8 @@ func rateLimitBackoff(min, max time.Duration, attemptNum int, resp *http.Respons // First create some jitter bounded by the min and max durations. jitter := time.Duration(rnd.Float64() * float64(max-min)) - if resp != nil && resp.Header.Get(headerRateReset) != "" { - v := resp.Header.Get(headerRateReset) + if resp != nil && resp.Header.Get(_headerRateReset) != "" { + v := resp.Header.Get(_headerRateReset) reset, err := strconv.ParseFloat(v, 64) if err != nil { log.Fatal(err) @@ -415,8 +422,8 @@ func (c *Client) getRawAPIMetadata() (rawAPIMetadata, error) { } resp.Body.Close() - meta.APIVersion = resp.Header.Get(headerAPIVersion) - meta.RateLimit = resp.Header.Get(headerRateLimit) + meta.APIVersion = resp.Header.Get(_headerAPIVersion) + meta.RateLimit = resp.Header.Get(_headerRateLimit) return meta, nil } diff --git a/workspace_run_task.go b/workspace_run_task.go new file mode 100644 index 000000000..40f64875b --- /dev/null +++ b/workspace_run_task.go @@ -0,0 +1,204 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation +var _ WorkspaceRunTasks = (*workspaceRunTasks)(nil) + +// WorkspaceRunTasks represent all the run task related methods in the context of a workspace that the Terraform Cloud/Enterprise API supports. +// **Note: This API is still in BETA and subject to change.** +type WorkspaceRunTasks interface { + // Add a run task to a workspace + Create(ctx context.Context, workspaceID string, options WorkspaceRunTaskCreateOptions) (*WorkspaceRunTask, error) + + // List all run tasks for a workspace + List(ctx context.Context, workspaceID string, options *WorkspaceRunTaskListOptions) (*WorkspaceRunTaskList, error) + + // Read a workspace run task by ID + Read(ctx context.Context, workspaceID string, workspaceTaskID string) (*WorkspaceRunTask, error) + + // Update a workspace run task by ID + Update(ctx context.Context, workspaceID string, workspaceTaskID string, options WorkspaceRunTaskUpdateOptions) (*WorkspaceRunTask, error) + + // Delete a workspace's run task by ID + Delete(ctx context.Context, workspaceID string, workspaceTaskID string) error +} + +// workspaceRunTasks implements WorkspaceRunTasks +type workspaceRunTasks struct { + client *Client +} + +// WorkspaceRunTask represents a TFC/E run task that belongs to a workspace +type WorkspaceRunTask struct { + ID string `jsonapi:"primary,workspace-tasks"` + EnforcementLevel TaskEnforcementLevel `jsonapi:"attr,enforcement-level"` + + RunTask *RunTask `jsonapi:"relation,task"` + Workspace *Workspace `jsonapi:"relation,workspace"` +} + +// WorkspaceRunTaskList represents a list of workspace run tasks +type WorkspaceRunTaskList struct { + *Pagination + Items []*WorkspaceRunTask +} + +// WorkspaceRunTaskListOptions represents the set of options for listing workspace run tasks +type WorkspaceRunTaskListOptions struct { + ListOptions +} + +// List all run tasks attached to a workspace +func (s *workspaceRunTasks) List(ctx context.Context, workspaceID string, options *WorkspaceRunTaskListOptions) (*WorkspaceRunTaskList, error) { + if !validStringID(&workspaceID) { + return nil, ErrInvalidWorkspaceID + } + + u := fmt.Sprintf("workspaces/%s/tasks", url.QueryEscape(workspaceID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + rl := &WorkspaceRunTaskList{} + err = s.client.do(ctx, req, rl) + if err != nil { + return nil, err + } + + return rl, nil +} + +// Read a workspace run task by ID +func (s *workspaceRunTasks) Read(ctx context.Context, workspaceID string, workspaceTaskID string) (*WorkspaceRunTask, error) { + if !validStringID(&workspaceID) { + return nil, ErrInvalidWorkspaceID + } + + if !validStringID(&workspaceTaskID) { + return nil, ErrInvalidWorkspaceRunTaskID + } + + u := fmt.Sprintf( + "workspaces/%s/tasks/%s", + url.QueryEscape(workspaceID), + url.QueryEscape(workspaceTaskID), + ) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + wr := &WorkspaceRunTask{} + err = s.client.do(ctx, req, wr) + if err != nil { + return nil, err + } + + return wr, nil +} + +// WorkspaceRunTaskCreateOptions represents the set of options for creating a workspace run task +type WorkspaceRunTaskCreateOptions struct { + Type string `jsonapi:"primary,workspace-tasks"` + // Required: The enforcement level for a run task + EnforcementLevel TaskEnforcementLevel `jsonapi:"attr,enforcement-level"` + // Required: The run task to attach to the workspace + RunTask *RunTask `jsonapi:"relation,task"` +} + +func (o *WorkspaceRunTaskCreateOptions) valid() error { + if o.RunTask.ID == "" { + return ErrInvalidRunTaskID + } + + return nil +} + +// Create is used to attach a run task to a workspace, or in other words: create a workspace run task. The run task must exist in the workspace's organization. +func (s *workspaceRunTasks) Create(ctx context.Context, workspaceID string, options WorkspaceRunTaskCreateOptions) (*WorkspaceRunTask, error) { + if !validStringID(&workspaceID) { + return nil, ErrInvalidWorkspaceID + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("workspaces/%s/tasks", workspaceID) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + wr := &WorkspaceRunTask{} + err = s.client.do(ctx, req, wr) + if err != nil { + return nil, err + } + + return wr, nil +} + +// WorkspaceRunTaskUpdateOptions represent the set of options for updating a workspace run task. +type WorkspaceRunTaskUpdateOptions struct { + Type string `jsonapi:"primary,workspace-tasks"` + EnforcementLevel TaskEnforcementLevel `jsonapi:"attr,enforcement-level,omitempty"` +} + +// Update an existing workspace run task by ID +func (s *workspaceRunTasks) Update(ctx context.Context, workspaceID string, workspaceTaskID string, options WorkspaceRunTaskUpdateOptions) (*WorkspaceRunTask, error) { + if !validStringID(&workspaceID) { + return nil, ErrInvalidWorkspaceID + } + + if !validStringID(&workspaceTaskID) { + return nil, ErrInvalidWorkspaceRunTaskID + } + + u := fmt.Sprintf( + "workspaces/%s/tasks/%s", + url.QueryEscape(workspaceID), + url.QueryEscape(workspaceTaskID), + ) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + wr := &WorkspaceRunTask{} + err = s.client.do(ctx, req, wr) + if err != nil { + return nil, err + } + + return wr, nil +} + +// Delete a workspace run task by ID +func (s *workspaceRunTasks) Delete(ctx context.Context, workspaceID string, workspaceTaskID string) error { + if !validStringID(&workspaceID) { + return ErrInvalidWorkspaceID + } + + if !validStringID(&workspaceTaskID) { + return ErrInvalidWorkspaceRunTaskType + } + + u := fmt.Sprintf( + "workspaces/%s/tasks/%s", + url.QueryEscape(workspaceID), + url.QueryEscape(workspaceTaskID), + ) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/workspace_run_task_integration_test.go b/workspace_run_task_integration_test.go new file mode 100644 index 000000000..234033588 --- /dev/null +++ b/workspace_run_task_integration_test.go @@ -0,0 +1,180 @@ +//go:build integration +// +build integration + +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceRunTasksCreate(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + t.Run("attach run task to workspace", func(t *testing.T) { + wr, err := client.WorkspaceRunTasks.Create(ctx, wkspaceTest.ID, WorkspaceRunTaskCreateOptions{ + EnforcementLevel: Mandatory, + RunTask: runTaskTest, + }) + + require.NoError(t, err) + assert.NotEmpty(t, wr.ID) + assert.Equal(t, wr.EnforcementLevel, Mandatory) + + t.Run("ensure run task is deserialized properly", func(t *testing.T) { + assert.NotEmpty(t, wr.RunTask.ID) + }) + }) +} + +func TestWorkspaceRunTasksList(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + runTaskTest1, runTaskTest1Cleanup := createRunTask(t, client, orgTest) + defer runTaskTest1Cleanup() + + runTaskTest2, runTaskTest2Cleanup := createRunTask(t, client, orgTest) + defer runTaskTest2Cleanup() + + _, wrTaskTest1Cleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest1) + defer wrTaskTest1Cleanup() + + _, wrTaskTest2Cleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest2) + defer wrTaskTest2Cleanup() + + t.Run("with no params", func(t *testing.T) { + wrTaskList, err := client.WorkspaceRunTasks.List(ctx, wkspaceTest.ID, nil) + require.NoError(t, err) + assert.NotNil(t, wrTaskList.Items) + assert.Equal(t, len(wrTaskList.Items), 2) + assert.NotEmpty(t, wrTaskList.Items[0].ID) + assert.NotEmpty(t, wrTaskList.Items[0].EnforcementLevel) + }) +} + +func TestWorkspaceRunTasksRead(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + wrTaskTest, wrTaskTestCleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest) + defer wrTaskTestCleanup() + + t.Run("by ID", func(t *testing.T) { + wr, err := client.WorkspaceRunTasks.Read(ctx, wkspaceTest.ID, wrTaskTest.ID) + require.NoError(t, err) + + assert.Equal(t, wrTaskTest.ID, wr.ID) + assert.Equal(t, wrTaskTest.EnforcementLevel, wr.EnforcementLevel) + + t.Run("ensure run task is deserialized", func(t *testing.T) { + assert.Equal(t, wr.RunTask.ID, runTaskTest.ID) + }) + + t.Run("ensure workspace is deserialized", func(t *testing.T) { + assert.Equal(t, wr.Workspace.ID, wkspaceTest.ID) + }) + }) +} + +func TestWorkspaceRunTasksUpdate(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + wrTaskTest, wrTaskTestCleanup := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest) + defer wrTaskTestCleanup() + + t.Run("rename task", func(t *testing.T) { + wr, err := client.WorkspaceRunTasks.Update(ctx, wkspaceTest.ID, wrTaskTest.ID, WorkspaceRunTaskUpdateOptions{ + EnforcementLevel: Mandatory, + }) + require.NoError(t, err) + + wr, err = client.WorkspaceRunTasks.Read(ctx, wkspaceTest.ID, wr.ID) + require.NoError(t, err) + + assert.Equal(t, wr.EnforcementLevel, Mandatory) + }) +} + +func TestWorkspaceRunTasksDelete(t *testing.T) { + skipIfBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + wkspaceTest, wkspaceTestCleanup := createWorkspace(t, client, orgTest) + defer wkspaceTestCleanup() + + runTaskTest, runTaskTestCleanup := createRunTask(t, client, orgTest) + defer runTaskTestCleanup() + + wrTaskTest, _ := createWorkspaceRunTask(t, client, wkspaceTest, runTaskTest) + + t.Run("with valid options", func(t *testing.T) { + err := client.WorkspaceRunTasks.Delete(ctx, wkspaceTest.ID, wrTaskTest.ID) + require.NoError(t, err) + + _, err = client.WorkspaceRunTasks.Read(ctx, wkspaceTest.ID, wrTaskTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the workspace run task does not exist", func(t *testing.T) { + err := client.WorkspaceRunTasks.Delete(ctx, wkspaceTest.ID, wrTaskTest.ID) + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("when the workspace does not exist", func(t *testing.T) { + wkspaceTestCleanup() + err := client.WorkspaceRunTasks.Delete(ctx, wkspaceTest.ID, wrTaskTest.ID) + assert.EqualError(t, err, ErrResourceNotFound.Error()) + }) +}