diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1d32ad9..3999f6d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +## Enhancements + +* Add support for enabling Stacks on an organization by @brandonc [#987](https://github.com/hashicorp/go-tfe/pull/987) +* Add support for filtering by key/value tags by @brandonc [#987](https://github.com/hashicorp/go-tfe/pull/987) + # v1.68.0 ## Enhancements diff --git a/helper_test.go b/helper_test.go index 9098674e5..62247005f 100644 --- a/helper_test.go +++ b/helper_test.go @@ -2519,6 +2519,12 @@ func upgradeOrganizationSubscription(t *testing.T, _ *Client, organization *Orga } func createProject(t *testing.T, client *Client, org *Organization) (*Project, func()) { + return createProjectWithOptions(t, client, org, ProjectCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + }) +} + +func createProjectWithOptions(t *testing.T, client *Client, org *Organization, options ProjectCreateOptions) (*Project, func()) { var orgCleanup func() if org == nil { @@ -2526,9 +2532,7 @@ func createProject(t *testing.T, client *Client, org *Organization) (*Project, f } ctx := context.Background() - p, err := client.Projects.Create(ctx, org.Name, ProjectCreateOptions{ - Name: randomStringWithoutSpecialChar(t), - }) + p, err := client.Projects.Create(ctx, org.Name, options) if err != nil { t.Fatal(err) } diff --git a/mocks/project_mocks.go b/mocks/project_mocks.go index 1f440a188..593c588e2 100644 --- a/mocks/project_mocks.go +++ b/mocks/project_mocks.go @@ -84,6 +84,21 @@ func (mr *MockProjectsMockRecorder) List(ctx, organization, options any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockProjects)(nil).List), ctx, organization, options) } +// ListTagBindings mocks base method. +func (m *MockProjects) ListTagBindings(ctx context.Context, projectID string) ([]*tfe.TagBinding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTagBindings", ctx, projectID) + ret0, _ := ret[0].([]*tfe.TagBinding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListTagBindings indicates an expected call of ListTagBindings. +func (mr *MockProjectsMockRecorder) ListTagBindings(ctx, projectID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTagBindings", reflect.TypeOf((*MockProjects)(nil).ListTagBindings), ctx, projectID) +} + // Read mocks base method. func (m *MockProjects) Read(ctx context.Context, projectID string) (*tfe.Project, error) { m.ctrl.T.Helper() diff --git a/mocks/workspace_mocks.go b/mocks/workspace_mocks.go index 4689ad6e1..9645b3378 100644 --- a/mocks/workspace_mocks.go +++ b/mocks/workspace_mocks.go @@ -186,6 +186,21 @@ func (mr *MockWorkspacesMockRecorder) ListRemoteStateConsumers(ctx, workspaceID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRemoteStateConsumers", reflect.TypeOf((*MockWorkspaces)(nil).ListRemoteStateConsumers), ctx, workspaceID, options) } +// ListTagBindings mocks base method. +func (m *MockWorkspaces) ListTagBindings(ctx context.Context, workspaceID string) ([]*tfe.TagBinding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTagBindings", ctx, workspaceID) + ret0, _ := ret[0].([]*tfe.TagBinding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListTagBindings indicates an expected call of ListTagBindings. +func (mr *MockWorkspacesMockRecorder) ListTagBindings(ctx, workspaceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTagBindings", reflect.TypeOf((*MockWorkspaces)(nil).ListTagBindings), ctx, workspaceID) +} + // ListTags mocks base method. func (m *MockWorkspaces) ListTags(ctx context.Context, workspaceID string, options *tfe.WorkspaceTagListOptions) (*tfe.TagList, error) { m.ctrl.T.Helper() diff --git a/organization.go b/organization.go index fb4d397a4..3bafae55c 100644 --- a/organization.go +++ b/organization.go @@ -300,6 +300,10 @@ type OrganizationUpdateOptions struct { // Optional: DefaultAgentPoolId default agent pool for workspaces, requires DefaultExecutionMode to be set to `agent` DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool,omitempty"` + + // Optional: StacksEnabled toggles whether stacks are enabled for the organization. This setting + // is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. + StacksEnabled *bool `jsonapi:"attr,stacks-enabled,omitempty"` } // ReadRunQueueOptions represents the options for showing the queue. diff --git a/project.go b/project.go index 201d9f5d8..a3af18a92 100644 --- a/project.go +++ b/project.go @@ -31,6 +31,9 @@ type Projects interface { // Delete a project. Delete(ctx context.Context, projectID string) error + + // ListTagBindings lists all tag bindings associated with the project. + ListTagBindings(ctx context.Context, projectID string) ([]*TagBinding, error) } // projects implements Projects @@ -67,6 +70,10 @@ type ProjectListOptions struct { // Optional: A query string to search projects by names. Query string `url:"q,omitempty"` + + // Optional: A filter string to list projects filtered by key/value tags. + // These are not annotated and therefore not encoded by go-querystring + TagBindings []*TagBinding } // ProjectCreateOptions represents the options for creating a project @@ -82,6 +89,9 @@ type ProjectCreateOptions struct { // Optional: A description for the project. Description *string `jsonapi:"attr,description,omitempty"` + + // Associated TagBindings of the project. + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` } // ProjectUpdateOptions represents the options for updating a project @@ -97,6 +107,10 @@ type ProjectUpdateOptions struct { // Optional: A description for the project. Description *string `jsonapi:"attr,description,omitempty"` + + // Associated TagBindings of the project. Note that this will replace + // all existing tag bindings. + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` } // List all projects. @@ -105,8 +119,13 @@ func (s *projects) List(ctx context.Context, organization string, options *Proje return nil, ErrInvalidOrg } + var tagFilters map[string][]string + if options != nil { + tagFilters = encodeTagFiltersAsParams(options.TagBindings) + } + u := fmt.Sprintf("organizations/%s/projects", url.PathEscape(organization)) - req, err := s.client.NewRequest("GET", u, options) + req, err := s.client.NewRequestWithAdditionalQueryParams("GET", u, options, tagFilters) if err != nil { return nil, err } @@ -166,6 +185,30 @@ func (s *projects) Read(ctx context.Context, projectID string) (*Project, error) return p, nil } +func (s *projects) ListTagBindings(ctx context.Context, projectID string) ([]*TagBinding, error) { + if !validStringID(&projectID) { + return nil, ErrInvalidProjectID + } + + u := fmt.Sprintf("projects/%s/tag-bindings", url.PathEscape(projectID)) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + + var list struct { + *Pagination + Items []*TagBinding + } + + err = req.Do(ctx, &list) + if err != nil { + return nil, err + } + + return list.Items, nil +} + // Update a project by its ID func (s *projects) Update(ctx context.Context, projectID string, options ProjectUpdateOptions) (*Project, error) { if !validStringID(&projectID) { diff --git a/projects_integration_test.go b/projects_integration_test.go index f4076dab9..f1879c25d 100644 --- a/projects_integration_test.go +++ b/projects_integration_test.go @@ -62,6 +62,55 @@ func TestProjectsList(t *testing.T) { assert.Nil(t, pl) assert.EqualError(t, err, ErrInvalidOrg.Error()) }) + + t.Run("when using a tags filter", func(t *testing.T) { + skipUnlessBeta(t) + + p1, wTestCleanup1 := createProjectWithOptions(t, client, orgTest, ProjectCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + TagBindings: []*TagBinding{ + {Key: "key1", Value: "value1"}, + {Key: "key2", Value: "value2a"}, + }, + }) + p2, wTestCleanup2 := createProjectWithOptions(t, client, orgTest, ProjectCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + TagBindings: []*TagBinding{ + {Key: "key2", Value: "value2b"}, + {Key: "key3", Value: "value3"}, + }, + }) + t.Cleanup(wTestCleanup1) + t.Cleanup(wTestCleanup2) + + // List all the workspaces under the given tag + pl, err := client.Projects.List(ctx, orgTest.Name, &ProjectListOptions{ + TagBindings: []*TagBinding{ + {Key: "key1"}, + }, + }) + assert.NoError(t, err) + assert.Len(t, pl.Items, 1) + assert.Contains(t, pl.Items, p1) + + pl2, err := client.Projects.List(ctx, orgTest.Name, &ProjectListOptions{ + TagBindings: []*TagBinding{ + {Key: "key2"}, + }, + }) + assert.NoError(t, err) + assert.Len(t, pl2.Items, 2) + assert.Contains(t, pl2.Items, p1, p2) + + pl3, err := client.Projects.List(ctx, orgTest.Name, &ProjectListOptions{ + TagBindings: []*TagBinding{ + {Key: "key2", Value: "value2b"}, + }, + }) + assert.NoError(t, err) + assert.Len(t, pl3.Items, 1) + assert.Contains(t, pl3.Items, p2) + }) } func TestProjectsRead(t *testing.T) { @@ -160,12 +209,24 @@ func TestProjectsUpdate(t *testing.T) { kAfter, err := client.Projects.Update(ctx, kBefore.ID, ProjectUpdateOptions{ Name: String("new project name"), Description: String("updated description"), + TagBindings: []*TagBinding{ + {Key: "foo", Value: "bar"}, + }, }) require.NoError(t, err) assert.Equal(t, kBefore.ID, kAfter.ID) assert.NotEqual(t, kBefore.Name, kAfter.Name) assert.NotEqual(t, kBefore.Description, kAfter.Description) + + if betaFeaturesEnabled() { + bindings, err := client.Projects.ListTagBindings(ctx, kAfter.ID) + require.NoError(t, err) + + assert.Len(t, bindings, 1) + assert.Equal(t, "foo", bindings[0].Key) + assert.Equal(t, "bar", bindings[0].Value) + } }) t.Run("when updating with invalid name", func(t *testing.T) { diff --git a/tag.go b/tag.go index a00e0abf4..03fef89e7 100644 --- a/tag.go +++ b/tag.go @@ -3,6 +3,8 @@ package tfe +import "fmt" + type TagList struct { *Pagination Items []*Tag @@ -13,3 +15,23 @@ type Tag struct { ID string `jsonapi:"primary,tags"` Name string `jsonapi:"attr,name,omitempty"` } + +type TagBinding struct { + ID string `jsonapi:"primary,tag-bindings"` + Key string `jsonapi:"attr,key"` + Value string `jsonapi:"attr,value,omitempty"` +} + +func encodeTagFiltersAsParams(filters []*TagBinding) map[string][]string { + if len(filters) == 0 { + return nil + } + + var tagFilter = make(map[string][]string, len(filters)) + for index, tag := range filters { + tagFilter[fmt.Sprintf("filter[tagged][%d][key]", index)] = []string{tag.Key} + tagFilter[fmt.Sprintf("filter[tagged][%d][value]", index)] = []string{tag.Value} + } + + return tagFilter +} diff --git a/workspace.go b/workspace.go index 41883e8a2..c6d5cfc05 100644 --- a/workspace.go +++ b/workspace.go @@ -131,6 +131,9 @@ type Workspaces interface { // DeleteDataRetentionPolicy deletes a workspace's data retention policy // **Note: This functionality is only available in Terraform Enterprise.** DeleteDataRetentionPolicy(ctx context.Context, workspaceID string) error + + // ListTagBindings lists all tag bindings associated with the workspace. + ListTagBindings(ctx context.Context, workspaceID string) ([]*TagBinding, error) } // workspaces implements Workspaces. @@ -208,6 +211,7 @@ type Workspace struct { CurrentConfigurationVersion *ConfigurationVersion `jsonapi:"relation,current-configuration-version,omitempty"` LockedBy *LockedByChoice `jsonapi:"polyrelation,locked-by"` Variables []*Variable `jsonapi:"relation,vars"` + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings"` // Deprecated: Use DataRetentionPolicyChoice instead. DataRetentionPolicy *DataRetentionPolicy @@ -329,6 +333,10 @@ type WorkspaceListOptions struct { // Optional: A filter string to list all the workspaces filtered by current run status. CurrentRunStatus string `url:"filter[current-run][status],omitempty"` + // Optional: A filter string to list workspaces filtered by key/value tags. + // These are not annotated and therefore not encoded by go-querystring + TagBindings []*TagBinding + // Optional: A list of relations to include. See available resources https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces#available-related-resources Include []WSIncludeOpt `url:"include,omitempty"` @@ -471,6 +479,9 @@ type WorkspaceCreateOptions struct { // Associated Project with the workspace. If not provided, default project // of the organization will be assigned to the workspace. Project *Project `jsonapi:"relation,project,omitempty"` + + // Associated TagBindings of the workspace. + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` } // TODO: move this struct out. VCSRepoOptions is used by workspaces, policy sets, and registry modules @@ -610,6 +621,10 @@ type WorkspaceUpdateOptions struct { // Associated Project with the workspace. If not provided, default project // of the organization will be assigned to the workspace Project *Project `jsonapi:"relation,project,omitempty"` + + // Associated TagBindings of the project. Note that this will replace + // all existing tag bindings. + TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` } // WorkspaceLockOptions represents the options for locking a workspace. @@ -700,8 +715,14 @@ func (s *workspaces) List(ctx context.Context, organization string, options *Wor return nil, err } + var tagFilters map[string][]string + if options != nil { + tagFilters = encodeTagFiltersAsParams(options.TagBindings) + } + + // Encode parameters that cannot be encoded by go-querystring u := fmt.Sprintf("organizations/%s/workspaces", url.PathEscape(organization)) - req, err := s.client.NewRequest("GET", u, options) + req, err := s.client.NewRequestWithAdditionalQueryParams("GET", u, options, tagFilters) if err != nil { return nil, err } @@ -715,6 +736,30 @@ func (s *workspaces) List(ctx context.Context, organization string, options *Wor return wl, nil } +func (s *workspaces) ListTagBindings(ctx context.Context, workspaceID string) ([]*TagBinding, error) { + if !validStringID(&workspaceID) { + return nil, ErrInvalidWorkspaceID + } + + u := fmt.Sprintf("workspaces/%s/tag-bindings", url.PathEscape(workspaceID)) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + + var list struct { + *Pagination + Items []*TagBinding + } + + err = req.Do(ctx, &list) + if err != nil { + return nil, err + } + + return list.Items, nil +} + // Create is used to create a new workspace. func (s *workspaces) Create(ctx context.Context, organization string, options WorkspaceCreateOptions) (*Workspace, error) { if !validStringID(&organization) { @@ -1436,7 +1481,7 @@ func (o WorkspaceCreateOptions) valid() error { if o.AgentPoolID == nil && (o.ExecutionMode != nil && *o.ExecutionMode == "agent") { return ErrRequiredAgentPoolID } - if o.TriggerPrefixes != nil && len(o.TriggerPrefixes) > 0 && + if len(o.TriggerPrefixes) > 0 && o.TriggerPatterns != nil && len(o.TriggerPatterns) > 0 { return ErrUnsupportedBothTriggerPatternsAndPrefixes } @@ -1466,7 +1511,7 @@ func (o WorkspaceUpdateOptions) valid() error { if o.AgentPoolID == nil && (o.ExecutionMode != nil && *o.ExecutionMode == "agent") { return ErrRequiredAgentPoolID } - if o.TriggerPrefixes != nil && len(o.TriggerPrefixes) > 0 && + if len(o.TriggerPrefixes) > 0 && o.TriggerPatterns != nil && len(o.TriggerPatterns) > 0 { return ErrUnsupportedBothTriggerPatternsAndPrefixes } diff --git a/workspace_integration_test.go b/workspace_integration_test.go index bcc90579f..2f1590481 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -250,6 +250,55 @@ func TestWorkspacesList(t *testing.T) { assert.Equal(t, 0, wl.TotalCount) }) + t.Run("when using a tags filter", func(t *testing.T) { + skipUnlessBeta(t) + + w1, wTestCleanup1 := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ + Name: String(randomString(t)), + TagBindings: []*TagBinding{ + {Key: "key1", Value: "value1"}, + {Key: "key2", Value: "value2a"}, + }, + }) + w2, wTestCleanup2 := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ + Name: String(randomString(t)), + TagBindings: []*TagBinding{ + {Key: "key2", Value: "value2b"}, + {Key: "key3", Value: "value3"}, + }, + }) + t.Cleanup(wTestCleanup1) + t.Cleanup(wTestCleanup2) + + // List all the workspaces under the given tag + wl, err := client.Workspaces.List(ctx, orgTest.Name, &WorkspaceListOptions{ + TagBindings: []*TagBinding{ + {Key: "key1"}, + }, + }) + assert.NoError(t, err) + assert.Len(t, wl.Items, 1) + assert.Contains(t, wl.Items, w1) + + wl2, err := client.Workspaces.List(ctx, orgTest.Name, &WorkspaceListOptions{ + TagBindings: []*TagBinding{ + {Key: "key2"}, + }, + }) + assert.NoError(t, err) + assert.Len(t, wl2.Items, 2) + assert.Contains(t, wl2.Items, w1, w2) + + wl3, err := client.Workspaces.List(ctx, orgTest.Name, &WorkspaceListOptions{ + TagBindings: []*TagBinding{ + {Key: "key2", Value: "value2b"}, + }, + }) + assert.NoError(t, err) + assert.Len(t, wl3.Items, 1) + assert.Contains(t, wl3.Items, w2) + }) + t.Run("when using project id filter and project contains workspaces", func(t *testing.T) { // create a project in the orgTest p, pTestCleanup := createProject(t, client, orgTest) @@ -1222,6 +1271,9 @@ func TestWorkspacesUpdate(t *testing.T) { TerraformVersion: String("0.11.1"), TriggerPrefixes: []string{"/modules", "/shared"}, WorkingDirectory: String("baz/"), + TagBindings: []*TagBinding{ + {Key: "foo", Value: "bar"}, + }, } w, err := client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, options) @@ -1249,6 +1301,15 @@ func TestWorkspacesUpdate(t *testing.T) { assert.Equal(t, options.TriggerPrefixes, item.TriggerPrefixes) assert.Equal(t, *options.WorkingDirectory, item.WorkingDirectory) } + + if betaFeaturesEnabled() { + bindings, err := client.Workspaces.ListTagBindings(ctx, wTest.ID) + require.NoError(t, err) + + assert.Len(t, bindings, 1) + assert.Equal(t, "foo", bindings[0].Key) + assert.Equal(t, "bar", bindings[0].Value) + } }) t.Run("when options includes both an operations value and an enforcement mode value", func(t *testing.T) {