diff --git a/CHANGELOG.md b/CHANGELOG.md index 846ad09c4..53e62bc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * 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) +* Add support for adding/updating key/value tags by @brandonc [#991](https://github.com/hashicorp/go-tfe/pull/991) * Add support for reading a registry module by its unique identifier by @dsa0x [#988](https://github.com/hashicorp/go-tfe/pull/988) # v1.68.0 diff --git a/errors.go b/errors.go index 9eff3cf19..935f5b361 100644 --- a/errors.go +++ b/errors.go @@ -382,6 +382,8 @@ var ( ErrRequiredRegistryModule = errors.New("registry module is required") + ErrRequiredTagBindings = errors.New("TagBindings are required") + ErrInvalidTestRunID = errors.New("invalid value for test run id") ErrTerraformVersionValidForPlanOnly = errors.New("setting terraform-version is only valid when plan-only is set to true") diff --git a/mocks/project_mocks.go b/mocks/project_mocks.go index 593c588e2..f6bc62656 100644 --- a/mocks/project_mocks.go +++ b/mocks/project_mocks.go @@ -40,6 +40,21 @@ func (m *MockProjects) EXPECT() *MockProjectsMockRecorder { return m.recorder } +// AddTagBindings mocks base method. +func (m *MockProjects) AddTagBindings(ctx context.Context, projectID string, options tfe.ProjectAddTagBindingsOptions) ([]*tfe.TagBinding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddTagBindings", ctx, projectID, options) + ret0, _ := ret[0].([]*tfe.TagBinding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddTagBindings indicates an expected call of AddTagBindings. +func (mr *MockProjectsMockRecorder) AddTagBindings(ctx, projectID, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTagBindings", reflect.TypeOf((*MockProjects)(nil).AddTagBindings), ctx, projectID, options) +} + // Create mocks base method. func (m *MockProjects) Create(ctx context.Context, organization string, options tfe.ProjectCreateOptions) (*tfe.Project, error) { m.ctrl.T.Helper() diff --git a/mocks/workspace_mocks.go b/mocks/workspace_mocks.go index 9645b3378..57fdd01e4 100644 --- a/mocks/workspace_mocks.go +++ b/mocks/workspace_mocks.go @@ -55,6 +55,21 @@ func (mr *MockWorkspacesMockRecorder) AddRemoteStateConsumers(ctx, workspaceID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRemoteStateConsumers", reflect.TypeOf((*MockWorkspaces)(nil).AddRemoteStateConsumers), ctx, workspaceID, options) } +// AddTagBindings mocks base method. +func (m *MockWorkspaces) AddTagBindings(ctx context.Context, workspaceID string, options tfe.WorkspaceAddTagBindingsOptions) ([]*tfe.TagBinding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddTagBindings", ctx, workspaceID, options) + ret0, _ := ret[0].([]*tfe.TagBinding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddTagBindings indicates an expected call of AddTagBindings. +func (mr *MockWorkspacesMockRecorder) AddTagBindings(ctx, workspaceID, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTagBindings", reflect.TypeOf((*MockWorkspaces)(nil).AddTagBindings), ctx, workspaceID, options) +} + // AddTags mocks base method. func (m *MockWorkspaces) AddTags(ctx context.Context, workspaceID string, options tfe.WorkspaceAddTagsOptions) error { m.ctrl.T.Helper() diff --git a/project.go b/project.go index a3af18a92..30e56d7b4 100644 --- a/project.go +++ b/project.go @@ -34,6 +34,9 @@ type Projects interface { // ListTagBindings lists all tag bindings associated with the project. ListTagBindings(ctx context.Context, projectID string) ([]*TagBinding, error) + + // AddTagBindings adds or modifies the value of existing tag binding keys for a project. + AddTagBindings(ctx context.Context, projectID string, options ProjectAddTagBindingsOptions) ([]*TagBinding, error) } // projects implements Projects @@ -113,6 +116,12 @@ type ProjectUpdateOptions struct { TagBindings []*TagBinding `jsonapi:"relation,tag-bindings,omitempty"` } +// ProjectAddTagBindingsOptions represents the options for adding tag bindings +// to a project. +type ProjectAddTagBindingsOptions struct { + TagBindings []*TagBinding +} + // List all projects. func (s *projects) List(ctx context.Context, organization string, options *ProjectListOptions) (*ProjectList, error) { if !validStringID(&organization) { @@ -209,6 +218,31 @@ func (s *projects) ListTagBindings(ctx context.Context, projectID string) ([]*Ta return list.Items, nil } +// AddTagBindings adds or modifies the value of existing tag binding keys for a project +func (s *projects) AddTagBindings(ctx context.Context, projectID string, options ProjectAddTagBindingsOptions) ([]*TagBinding, error) { + if !validStringID(&projectID) { + return nil, ErrInvalidProjectID + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("projects/%s/tag-bindings", url.PathEscape(projectID)) + req, err := s.client.NewRequest("PATCH", u, options.TagBindings) + if err != nil { + return nil, err + } + + var response = struct { + *Pagination + Items []*TagBinding + }{} + err = req.Do(ctx, &response) + + return response.Items, err +} + // Update a project by its ID func (s *projects) Update(ctx context.Context, projectID string, options ProjectUpdateOptions) (*Project, error) { if !validStringID(&projectID) { @@ -259,3 +293,11 @@ func (o ProjectCreateOptions) valid() error { func (o ProjectUpdateOptions) valid() error { return nil } + +func (o ProjectAddTagBindingsOptions) valid() error { + if len(o.TagBindings) == 0 { + return ErrRequiredTagBindings + } + + return nil +} diff --git a/projects_integration_test.go b/projects_integration_test.go index f1879c25d..2d851b9fb 100644 --- a/projects_integration_test.go +++ b/projects_integration_test.go @@ -66,22 +66,22 @@ func TestProjectsList(t *testing.T) { t.Run("when using a tags filter", func(t *testing.T) { skipUnlessBeta(t) - p1, wTestCleanup1 := createProjectWithOptions(t, client, orgTest, ProjectCreateOptions{ + p1, pTestCleanup1 := 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{ + p2, pTestCleanup2 := createProjectWithOptions(t, client, orgTest, ProjectCreateOptions{ Name: randomStringWithoutSpecialChar(t), TagBindings: []*TagBinding{ {Key: "key2", Value: "value2b"}, {Key: "key3", Value: "value3"}, }, }) - t.Cleanup(wTestCleanup1) - t.Cleanup(wTestCleanup2) + t.Cleanup(pTestCleanup1) + t.Cleanup(pTestCleanup2) // List all the workspaces under the given tag pl, err := client.Projects.List(ctx, orgTest.Name, &ProjectListOptions{ @@ -247,6 +247,70 @@ func TestProjectsUpdate(t *testing.T) { }) } +func TestProjectsAddTagBindings(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + pTest, wCleanup := createProject(t, client, nil) + t.Cleanup(wCleanup) + + t.Run("when adding tag bindings to a project", func(t *testing.T) { + tagBindings := []*TagBinding{ + {Key: "foo", Value: "bar"}, + {Key: "baz", Value: "qux"}, + } + + bindings, err := client.Projects.AddTagBindings(ctx, pTest.ID, ProjectAddTagBindingsOptions{ + TagBindings: tagBindings, + }) + require.NoError(t, err) + + require.Len(t, bindings, 2) + assert.Equal(t, tagBindings[0].Key, bindings[0].Key) + assert.Equal(t, tagBindings[0].Value, bindings[0].Value) + assert.Equal(t, tagBindings[1].Key, bindings[1].Key) + assert.Equal(t, tagBindings[1].Value, bindings[1].Value) + }) + + t.Run("when adding 26 tags", func(t *testing.T) { + tagBindings := []*TagBinding{ + {Key: "alpha"}, + {Key: "bravo"}, + {Key: "charlie"}, + {Key: "delta"}, + {Key: "echo"}, + {Key: "foxtrot"}, + {Key: "golf"}, + {Key: "hotel"}, + {Key: "india"}, + {Key: "juliet"}, + {Key: "kilo"}, + {Key: "lima"}, + {Key: "mike"}, + {Key: "november"}, + {Key: "oscar"}, + {Key: "papa"}, + {Key: "quebec"}, + {Key: "romeo"}, + {Key: "sierra"}, + {Key: "tango"}, + {Key: "uniform"}, + {Key: "victor"}, + {Key: "whiskey"}, + {Key: "xray"}, + {Key: "yankee"}, + {Key: "zulu"}, + } + + _, err := client.Workspaces.AddTagBindings(ctx, pTest.ID, WorkspaceAddTagBindingsOptions{ + TagBindings: tagBindings, + }) + require.Error(t, err, "cannot exceed 10 bindings per resource") + }) +} + func TestProjectsDelete(t *testing.T) { client := testClient(t) ctx := context.Background() diff --git a/workspace.go b/workspace.go index c6d5cfc05..721bfd148 100644 --- a/workspace.go +++ b/workspace.go @@ -134,6 +134,9 @@ type Workspaces interface { // ListTagBindings lists all tag bindings associated with the workspace. ListTagBindings(ctx context.Context, workspaceID string) ([]*TagBinding, error) + + // AddTagBindings adds or modifies the value of existing tag binding keys for a workspace. + AddTagBindings(ctx context.Context, workspaceID string, options WorkspaceAddTagBindingsOptions) ([]*TagBinding, error) } // workspaces implements Workspaces. @@ -147,6 +150,12 @@ type WorkspaceList struct { Items []*Workspace } +// WorkspaceAddTagBindingsOptions represents the options for adding tag bindings +// to a workspace. +type WorkspaceAddTagBindingsOptions struct { + TagBindings []*TagBinding +} + // LockedByChoice is a choice type struct that represents the possible values // within a polymorphic relation. If a value is available, exactly one field // will be non-nil. @@ -760,6 +769,31 @@ func (s *workspaces) ListTagBindings(ctx context.Context, workspaceID string) ([ return list.Items, nil } +// AddTagBindings adds or modifies the value of existing tag binding keys for a workspace. +func (s *workspaces) AddTagBindings(ctx context.Context, workspaceID string, options WorkspaceAddTagBindingsOptions) ([]*TagBinding, error) { + if !validStringID(&workspaceID) { + return nil, ErrInvalidWorkspaceID + } + + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("workspaces/%s/tag-bindings", url.PathEscape(workspaceID)) + req, err := s.client.NewRequest("PATCH", u, options.TagBindings) + if err != nil { + return nil, err + } + + var response = struct { + *Pagination + Items []*TagBinding + }{} + err = req.Do(ctx, &response) + + return response.Items, err +} + // Create is used to create a new workspace. func (s *workspaces) Create(ctx context.Context, organization string, options WorkspaceCreateOptions) (*Workspace, error) { if !validStringID(&organization) { @@ -1465,6 +1499,14 @@ func (s *workspaces) DeleteDataRetentionPolicy(ctx context.Context, workspaceID return req.Do(ctx, nil) } +func (o WorkspaceAddTagBindingsOptions) valid() error { + if len(o.TagBindings) == 0 { + return ErrRequiredTagBindings + } + + return nil +} + func (o WorkspaceCreateOptions) valid() error { if !validString(o.Name) { return ErrRequiredName diff --git a/workspace_integration_test.go b/workspace_integration_test.go index 2f1590481..0a825c8c2 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -1173,6 +1173,70 @@ func TestWorkspacesReadByID(t *testing.T) { }) } +func TestWorkspacesAddTagBindings(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + wTest, wCleanup := createWorkspace(t, client, nil) + t.Cleanup(wCleanup) + + t.Run("when adding tag bindings to a workspace", func(t *testing.T) { + tagBindings := []*TagBinding{ + {Key: "foo", Value: "bar"}, + {Key: "baz", Value: "qux"}, + } + + bindings, err := client.Workspaces.AddTagBindings(ctx, wTest.ID, WorkspaceAddTagBindingsOptions{ + TagBindings: tagBindings, + }) + require.NoError(t, err) + + require.Len(t, bindings, 2) + assert.Equal(t, tagBindings[0].Key, bindings[0].Key) + assert.Equal(t, tagBindings[0].Value, bindings[0].Value) + assert.Equal(t, tagBindings[1].Key, bindings[1].Key) + assert.Equal(t, tagBindings[1].Value, bindings[1].Value) + }) + + t.Run("when adding 26 tags", func(t *testing.T) { + tagBindings := []*TagBinding{ + {Key: "alpha"}, + {Key: "bravo"}, + {Key: "charlie"}, + {Key: "delta"}, + {Key: "echo"}, + {Key: "foxtrot"}, + {Key: "golf"}, + {Key: "hotel"}, + {Key: "india"}, + {Key: "juliet"}, + {Key: "kilo"}, + {Key: "lima"}, + {Key: "mike"}, + {Key: "november"}, + {Key: "oscar"}, + {Key: "papa"}, + {Key: "quebec"}, + {Key: "romeo"}, + {Key: "sierra"}, + {Key: "tango"}, + {Key: "uniform"}, + {Key: "victor"}, + {Key: "whiskey"}, + {Key: "xray"}, + {Key: "yankee"}, + {Key: "zulu"}, + } + + _, err := client.Workspaces.AddTagBindings(ctx, wTest.ID, WorkspaceAddTagBindingsOptions{ + TagBindings: tagBindings, + }) + require.Error(t, err, "cannot exceed 10 bindings per resource") + }) +} + func TestWorkspacesUpdate(t *testing.T) { client := testClient(t) ctx := context.Background()