diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f313e249..a338b4cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # UNRELEASED +## Enhancements + +* Adds BETA support for `Stacks` resources, which is EXPERIMENTAL, SUBJECT TO CHANGE, and may not be available to all users. [#920](https://github.com/hashicorp/go-tfe/pull/920) + # v1.57.0 ## Enhancements diff --git a/stack.go b/stack.go new file mode 100644 index 000000000..3e881128b --- /dev/null +++ b/stack.go @@ -0,0 +1,209 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" + "time" +) + +// Stacks describes all the stacks-related methods that the HCP Terraform API supports. +// NOTE WELL: This is a beta feature and is subject to change until noted otherwise in the +// release notes. +type Stacks interface { + // List returns a list of stacks, optionally filtered by project. + List(ctx context.Context, organization string, options *StackListOptions) (*StackList, error) + + // Read returns a stack by its ID. + Read(ctx context.Context, stackID string) (*Stack, error) + + // Create creates a new stack. + Create(ctx context.Context, options StackCreateOptions) (*Stack, error) + + // Update updates a stack. + Update(ctx context.Context, stackID string, options StackUpdateOptions) (*Stack, error) + + // Delete deletes a stack. + Delete(ctx context.Context, stackID string) error +} + +// stacks implements Stacks. +type stacks struct { + client *Client +} + +var _ Stacks = &stacks{} + +// StackSortColumn represents a string that can be used to sort items when using +// the List method. +type StackSortColumn string + +const ( + // StackSortByName sorts by the name attribute. + StackSortByName StackSortColumn = "name" + + // StackSortByUpdatedAt sorts by the updated-at attribute. + StackSortByUpdatedAt StackSortColumn = "updated-at" + + // StackSortByNameDesc sorts by the name attribute in descending order. + StackSortByNameDesc StackSortColumn = "-name" + + // StackSortByUpdatedAtDesc sorts by the updated-at attribute in descending order. + StackSortByUpdatedAtDesc StackSortColumn = "-updated-at" +) + +// StackList represents a list of stacks. +type StackList struct { + *Pagination + Items []*Stack +} + +// StackVCSRepo represents the version control system repository for a stack. +type StackVCSRepo struct { + Identifier string `jsonapi:"attr,identifier"` + Branch string `jsonapi:"attr,branch"` + GHAInstallationID string `jsonapi:"attr,github-app-installation-id"` + OAuthTokenID string `jsonapi:"attr,oauth-token-id"` +} + +// Stack represents a stack. +type Stack struct { + ID string `jsonapi:"primary,stacks"` + Name string `jsonapi:"attr,name"` + Description string `jsonapi:"attr,description"` + DeploymentNames []string `jsonapi:"attr,deployment-names"` + VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo"` + ErrorsCount int `jsonapi:"attr,errors-count"` + WarningsCount int `jsonapi:"attr,warnings-count"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` + + // Relationships + Project *Project `jsonapi:"relation,project"` +} + +// StackListOptions represents the options for listing stacks. +type StackListOptions struct { + ListOptions + ProjectID string `url:"filter[project[id]],omitempty"` + Sort StackSortColumn `url:"sort,omitempty"` + SearchByName string `url:"search[name],omitempty"` +} + +// StackCreateOptions represents the options for creating a stack. The project +// relation is required. +type StackCreateOptions struct { + Type string `jsonapi:"primary,stacks"` + Name string `jsonapi:"attr,name"` + Description *string `jsonapi:"attr,description,omitempty"` + VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo"` + Project *Project `jsonapi:"relation,project"` +} + +// StackUpdateOptions represents the options for updating a stack. +type StackUpdateOptions struct { + Name *string `jsonapi:"attr,name,omitempty"` + Description *string `jsonapi:"attr,description,omitempty"` + VCSRepo *StackVCSRepo `jsonapi:"attr,vcs-repo,omitempty"` +} + +func (s stacks) List(ctx context.Context, organization string, options *StackListOptions) (*StackList, error) { + if err := options.valid(); err != nil { + return nil, err + } + + req, err := s.client.NewRequest("GET", fmt.Sprintf("organizations/%s/stacks", organization), options) + if err != nil { + return nil, err + } + + sl := &StackList{} + err = req.Do(ctx, sl) + if err != nil { + return nil, err + } + + return sl, nil +} + +func (s stacks) Read(ctx context.Context, stackID string) (*Stack, error) { + req, err := s.client.NewRequest("GET", fmt.Sprintf("stacks/%s", url.PathEscape(stackID)), nil) + if err != nil { + return nil, err + } + + stack := &Stack{} + err = req.Do(ctx, stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +func (s stacks) Create(ctx context.Context, options StackCreateOptions) (*Stack, error) { + if err := options.valid(); err != nil { + return nil, err + } + + req, err := s.client.NewRequest("POST", "stacks", &options) + if err != nil { + return nil, err + } + + stack := &Stack{} + err = req.Do(ctx, stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +func (s stacks) Update(ctx context.Context, stackID string, options StackUpdateOptions) (*Stack, error) { + req, err := s.client.NewRequest("PATCH", fmt.Sprintf("stacks/%s", url.PathEscape(stackID)), &options) + if err != nil { + return nil, err + } + + stack := &Stack{} + err = req.Do(ctx, stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +func (s stacks) Delete(ctx context.Context, stackID string) error { + req, err := s.client.NewRequest("POST", fmt.Sprintf("stacks/%s/delete", url.PathEscape(stackID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + +func (s *StackListOptions) valid() error { + return nil +} + +func (s StackCreateOptions) valid() error { + if s.Name == "" { + return ErrRequiredName + } + + if s.Project.ID == "" { + return ErrRequiredProject + } + + return s.VCSRepo.valid() +} + +func (s StackVCSRepo) valid() error { + if s.Identifier == "" { + return ErrRequiredVCSRepo + } + + return nil +} diff --git a/stack_integration_test.go b/stack_integration_test.go new file mode 100644 index 000000000..39ab596bd --- /dev/null +++ b/stack_integration_test.go @@ -0,0 +1,172 @@ +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStackCreateAndList(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + project2, err := client.Projects.Create(ctx, orgTest.Name, ProjectCreateOptions{ + Name: "test-project-2", + }) + require.NoError(t, err) + + oauthClient, cleanup := createOAuthClient(t, client, orgTest, nil) + t.Cleanup(cleanup) + + stack1, err := client.Stacks.Create(ctx, StackCreateOptions{ + Name: "aa-test-stack", + VCSRepo: &StackVCSRepo{ + Identifier: "hashicorp-guides/pet-nulls-stack", + OAuthTokenID: oauthClient.OAuthTokens[0].ID, + }, + Project: &Project{ + ID: orgTest.DefaultProject.ID, + }, + }) + + require.NoError(t, err) + require.NotNil(t, stack1) + + stack2, err := client.Stacks.Create(ctx, StackCreateOptions{ + Name: "zz-test-stack", + VCSRepo: &StackVCSRepo{ + Identifier: "hashicorp-guides/pet-nulls-stack", + OAuthTokenID: oauthClient.OAuthTokens[0].ID, + }, + Project: &Project{ + ID: project2.ID, + }, + }) + + require.NoError(t, err) + require.NotNil(t, stack2) + + t.Run("List without options", func(t *testing.T) { + t.Parallel() + + stackList, err := client.Stacks.List(ctx, orgTest.Name, nil) + require.NoError(t, err) + + assert.Len(t, stackList.Items, 2) + }) + + t.Run("List with project filter", func(t *testing.T) { + t.Parallel() + + stackList, err := client.Stacks.List(ctx, orgTest.Name, &StackListOptions{ + ProjectID: project2.ID, + }) + require.NoError(t, err) + + assert.Len(t, stackList.Items, 1) + assert.Equal(t, stack2.ID, stackList.Items[0].ID) + }) + + t.Run("List with name filter", func(t *testing.T) { + t.Parallel() + + stackList, err := client.Stacks.List(ctx, orgTest.Name, &StackListOptions{ + SearchByName: "zz", + }) + require.NoError(t, err) + + assert.Len(t, stackList.Items, 1) + assert.Equal(t, stack2.ID, stackList.Items[0].ID) + }) + + t.Run("List with sort options", func(t *testing.T) { + t.Parallel() + + // By name ASC + stackList, err := client.Stacks.List(ctx, orgTest.Name, &StackListOptions{ + Sort: StackSortByName, + }) + require.NoError(t, err) + + assert.Len(t, stackList.Items, 2) + assert.Equal(t, stack1.ID, stackList.Items[0].ID) + + // By name DESC + stackList, err = client.Stacks.List(ctx, orgTest.Name, &StackListOptions{ + Sort: StackSortByNameDesc, + }) + require.NoError(t, err) + + assert.Len(t, stackList.Items, 2) + assert.Equal(t, stack2.ID, stackList.Items[0].ID) + }) + + t.Run("List with pagination", func(t *testing.T) { + t.Parallel() + + stackList, err := client.Stacks.List(ctx, orgTest.Name, &StackListOptions{ + ListOptions: ListOptions{ + PageNumber: 1, + PageSize: 1, + }, + }) + require.NoError(t, err) + + assert.Len(t, stackList.Items, 1) + assert.Equal(t, 2, stackList.Pagination.TotalPages) + assert.Equal(t, 2, stackList.Pagination.TotalCount) + }) +} + +func TestStackReadUpdateDelete(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + oauthClient, cleanup := createOAuthClient(t, client, orgTest, nil) + t.Cleanup(cleanup) + + stack, err := client.Stacks.Create(ctx, StackCreateOptions{ + Name: "test-stack", + VCSRepo: &StackVCSRepo{ + Identifier: "brandonc/pet-nulls-stack", + OAuthTokenID: oauthClient.OAuthTokens[0].ID, + }, + Project: &Project{ + ID: orgTest.DefaultProject.ID, + }, + }) + + require.NoError(t, err) + require.NotNil(t, stack) + + stackRead, err := client.Stacks.Read(ctx, stack.ID) + require.NoError(t, err) + + assert.Equal(t, stack, stackRead) + + stackUpdated, err := client.Stacks.Update(ctx, stack.ID, StackUpdateOptions{ + Description: String("updated description"), + }) + + require.NoError(t, err) + require.Equal(t, "updated description", stackUpdated.Description) + + err = client.Stacks.Delete(ctx, stack.ID) + require.NoError(t, err) + + stackReadAfterDelete, err := client.Stacks.Read(ctx, stack.ID) + require.ErrorIs(t, err, ErrResourceNotFound) + require.Nil(t, stackReadAfterDelete) +} diff --git a/tfe.go b/tfe.go index 49ac6b36e..8b301722e 100644 --- a/tfe.go +++ b/tfe.go @@ -160,6 +160,7 @@ type Client struct { RunTasks RunTasks RunTriggers RunTriggers SSHKeys SSHKeys + Stacks Stacks StateVersionOutputs StateVersionOutputs StateVersions StateVersions TaskResults TaskResults @@ -460,6 +461,7 @@ func NewClient(cfg *Config) (*Client, error) { client.RunTasks = &runTasks{client: client} client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} + client.Stacks = &stacks{client: client} client.StateVersionOutputs = &stateVersionOutputs{client: client} client.StateVersions = &stateVersions{client: client} client.TaskResults = &taskResults{client: client}