diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf8ecb90..08c013d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased +## Enhancements + +* Add variable set support for stacks with `ApplyToStacks`, `RemoveFromStacks`, and `UpdateStacks` API methods by @nithishravindra [#1251](https://github.com/hashicorp/go-tfe/pull/1251) + # v1.96.0 ## Enhancements diff --git a/errors.go b/errors.go index 94d512906..ae83eb264 100644 --- a/errors.go +++ b/errors.go @@ -323,6 +323,8 @@ var ( ErrRequiredProjectID = errors.New("project ID is required") + ErrRequiredStackID = errors.New("stack ID is required") + ErrWorkspacesRequired = errors.New("workspaces is required") ErrWorkspaceMinLimit = errors.New("must provide at least one workspace") @@ -379,6 +381,8 @@ var ( ErrRequiredWorkspacesList = errors.New("no workspaces list provided") + ErrRequiredStacksList = errors.New("no stacks list provided") + ErrCommentBody = errors.New("comment body is required") ErrEmptyTeamName = errors.New("team name can not be empty") diff --git a/mocks/variable_set_mocks.go b/mocks/variable_set_mocks.go index 0179072a0..b3b7a1d83 100644 --- a/mocks/variable_set_mocks.go +++ b/mocks/variable_set_mocks.go @@ -54,6 +54,20 @@ func (mr *MockVariableSetsMockRecorder) ApplyToProjects(ctx, variableSetID, opti return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyToProjects", reflect.TypeOf((*MockVariableSets)(nil).ApplyToProjects), ctx, variableSetID, options) } +// ApplyToStacks mocks base method. +func (m *MockVariableSets) ApplyToStacks(ctx context.Context, variableSetID string, options *tfe.VariableSetApplyToStacksOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyToStacks", ctx, variableSetID, options) + ret0, _ := ret[0].(error) + return ret0 +} + +// ApplyToStacks indicates an expected call of ApplyToStacks. +func (mr *MockVariableSetsMockRecorder) ApplyToStacks(ctx, variableSetID, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyToStacks", reflect.TypeOf((*MockVariableSets)(nil).ApplyToStacks), ctx, variableSetID, options) +} + // ApplyToWorkspaces mocks base method. func (m *MockVariableSets) ApplyToWorkspaces(ctx context.Context, variableSetID string, options *tfe.VariableSetApplyToWorkspacesOptions) error { m.ctrl.T.Helper() @@ -171,6 +185,20 @@ func (mr *MockVariableSetsMockRecorder) RemoveFromProjects(ctx, variableSetID, o return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveFromProjects", reflect.TypeOf((*MockVariableSets)(nil).RemoveFromProjects), ctx, variableSetID, options) } +// RemoveFromStacks mocks base method. +func (m *MockVariableSets) RemoveFromStacks(ctx context.Context, variableSetID string, options *tfe.VariableSetRemoveFromStacksOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveFromStacks", ctx, variableSetID, options) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveFromStacks indicates an expected call of RemoveFromStacks. +func (mr *MockVariableSetsMockRecorder) RemoveFromStacks(ctx, variableSetID, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveFromStacks", reflect.TypeOf((*MockVariableSets)(nil).RemoveFromStacks), ctx, variableSetID, options) +} + // RemoveFromWorkspaces mocks base method. func (m *MockVariableSets) RemoveFromWorkspaces(ctx context.Context, variableSetID string, options *tfe.VariableSetRemoveFromWorkspacesOptions) error { m.ctrl.T.Helper() @@ -200,6 +228,21 @@ func (mr *MockVariableSetsMockRecorder) Update(ctx, variableSetID, options any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockVariableSets)(nil).Update), ctx, variableSetID, options) } +// UpdateStacks mocks base method. +func (m *MockVariableSets) UpdateStacks(ctx context.Context, variableSetID string, options *tfe.VariableSetUpdateStacksOptions) (*tfe.VariableSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateStacks", ctx, variableSetID, options) + ret0, _ := ret[0].(*tfe.VariableSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateStacks indicates an expected call of UpdateStacks. +func (mr *MockVariableSetsMockRecorder) UpdateStacks(ctx, variableSetID, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStacks", reflect.TypeOf((*MockVariableSets)(nil).UpdateStacks), ctx, variableSetID, options) +} + // UpdateWorkspaces mocks base method. func (m *MockVariableSets) UpdateWorkspaces(ctx context.Context, variableSetID string, options *tfe.VariableSetUpdateWorkspacesOptions) (*tfe.VariableSet, error) { m.ctrl.T.Helper() diff --git a/variable_set.go b/variable_set.go index 5a100f0bd..4791bfb52 100644 --- a/variable_set.go +++ b/variable_set.go @@ -50,8 +50,17 @@ type VariableSets interface { // Remove variable set from projects in the supplied list. RemoveFromProjects(ctx context.Context, variableSetID string, options VariableSetRemoveFromProjectsOptions) error + // Apply variable set to stacks in the supplied list. + ApplyToStacks(ctx context.Context, variableSetID string, options *VariableSetApplyToStacksOptions) error + + // Remove variable set from stacks in the supplied list. + RemoveFromStacks(ctx context.Context, variableSetID string, options *VariableSetRemoveFromStacksOptions) error + // Update list of workspaces to which the variable set is applied to match the supplied list. UpdateWorkspaces(ctx context.Context, variableSetID string, options *VariableSetUpdateWorkspacesOptions) (*VariableSet, error) + + // Update list of stacks to which the variable set is applied to match the supplied list. + UpdateStacks(ctx context.Context, variableSetID string, options *VariableSetUpdateStacksOptions) (*VariableSet, error) } // variableSets implements VariableSets. @@ -87,6 +96,7 @@ type VariableSet struct { Parent *Parent `jsonapi:"polyrelation,parent"` Workspaces []*Workspace `jsonapi:"relation,workspaces,omitempty"` Projects []*Project `jsonapi:"relation,projects,omitempty"` + Stacks []*Stack `jsonapi:"relation,stacks,omitempty"` Variables []*VariableSetVariable `jsonapi:"relation,vars,omitempty"` } @@ -97,6 +107,7 @@ type VariableSetIncludeOpt string const ( VariableSetWorkspaces VariableSetIncludeOpt = "workspaces" VariableSetProjects VariableSetIncludeOpt = "projects" + VariableSetStacks VariableSetIncludeOpt = "stacks" VariableSetVars VariableSetIncludeOpt = "vars" ) @@ -185,12 +196,24 @@ type VariableSetApplyToProjectsOptions struct { Projects []*Project } +// VariableSetApplyToStacksOptions represents the options for applying variable sets to stacks. +type VariableSetApplyToStacksOptions struct { + // The stacks to apply the variable set to (additive). + Stacks []*Stack +} + // VariableSetRemoveFromProjectsOptions represents the options for removing variable sets from projects. type VariableSetRemoveFromProjectsOptions struct { // The projects to remove the variable set from. Projects []*Project } +// VariableSetRemoveFromStacksOptions represents the options for removing variable sets from stacks. +type VariableSetRemoveFromStacksOptions struct { + // The stacks to remove the variable set from. + Stacks []*Stack +} + // VariableSetUpdateWorkspacesOptions represents a subset of update options specifically for applying variable sets to workspaces type VariableSetUpdateWorkspacesOptions struct { // Type is a public field utilized by JSON:API to @@ -203,12 +226,30 @@ type VariableSetUpdateWorkspacesOptions struct { Workspaces []*Workspace `jsonapi:"relation,workspaces"` } +// VariableSetUpdateStacksOptions represents a subset of update options specifically for applying variable sets to stacks +type VariableSetUpdateStacksOptions 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,varsets"` + + // The stacks to be applied to. An empty set means remove all applied + Stacks []*Stack `jsonapi:"relation,stacks"` +} + type privateVariableSetUpdateWorkspacesOptions struct { Type string `jsonapi:"primary,varsets"` Global bool `jsonapi:"attr,global"` Workspaces []*Workspace `jsonapi:"relation,workspaces"` } +type privateVariableSetUpdateStacksOptions struct { + Type string `jsonapi:"primary,varsets"` + Global bool `jsonapi:"attr,global"` + Stacks []*Stack `jsonapi:"relation,stacks"` +} + // List all Variable Sets in the organization func (s *variableSets) List(ctx context.Context, organization string, options *VariableSetListOptions) (*VariableSetList, error) { if !validStringID(&organization) { @@ -444,6 +485,42 @@ func (s variableSets) RemoveFromProjects(ctx context.Context, variableSetID stri return req.Do(ctx, nil) } +// ApplyToStacks applies the variable set to stacks in the supplied list. +// This method will return an error if the variable set has global = true. +func (s *variableSets) ApplyToStacks(ctx context.Context, variableSetID string, options *VariableSetApplyToStacksOptions) error { + if !validStringID(&variableSetID) { + return ErrInvalidVariableSetID + } + if err := options.valid(); err != nil { + return err + } + + u := fmt.Sprintf("varsets/%s/relationships/stacks", url.PathEscape(variableSetID)) + req, err := s.client.NewRequest("POST", u, options.Stacks) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + +func (s *variableSets) RemoveFromStacks(ctx context.Context, variableSetID string, options *VariableSetRemoveFromStacksOptions) error { + if !validStringID(&variableSetID) { + return ErrInvalidVariableSetID + } + if err := options.valid(); err != nil { + return err + } + + u := fmt.Sprintf("varsets/%s/relationships/stacks", url.PathEscape(variableSetID)) + req, err := s.client.NewRequest("DELETE", u, options.Stacks) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + // Update variable set to be applied to only the workspaces in the supplied list. func (s *variableSets) UpdateWorkspaces(ctx context.Context, variableSetID string, options *VariableSetUpdateWorkspacesOptions) (*VariableSet, error) { if err := options.valid(); err != nil { @@ -472,6 +549,34 @@ func (s *variableSets) UpdateWorkspaces(ctx context.Context, variableSetID strin return v, nil } +// Update variable set to be applied to only the stacks in the supplied list. +func (s *variableSets) UpdateStacks(ctx context.Context, variableSetID string, options *VariableSetUpdateStacksOptions) (*VariableSet, error) { + if err := options.valid(); err != nil { + return nil, err + } + + // Use private struct to ensure global is set to false when applying to stacks + o := privateVariableSetUpdateStacksOptions{ + Global: bool(false), + Stacks: options.Stacks, + } + + // We force inclusion of stacks as that is the primary data for which we are concerned with confirming changes. + u := fmt.Sprintf("varsets/%s?include=%s", url.PathEscape(variableSetID), VariableSetStacks) + req, err := s.client.NewRequest("PATCH", u, &o) + if err != nil { + return nil, err + } + + v := &VariableSet{} + err = req.Do(ctx, v) + if err != nil { + return nil, err + } + + return v, nil +} + func (o *VariableSetListOptions) valid() error { return nil } @@ -525,9 +630,34 @@ func (o VariableSetRemoveFromProjectsOptions) valid() error { return nil } +func (o VariableSetApplyToStacksOptions) valid() error { + for _, s := range o.Stacks { + if !validStringID(&s.ID) { + return ErrRequiredStackID + } + } + return nil +} + +func (o VariableSetRemoveFromStacksOptions) valid() error { + for _, s := range o.Stacks { + if !validStringID(&s.ID) { + return ErrRequiredStackID + } + } + return nil +} + func (o *VariableSetUpdateWorkspacesOptions) valid() error { if o == nil || o.Workspaces == nil { return ErrRequiredWorkspacesList } return nil } + +func (o *VariableSetUpdateStacksOptions) valid() error { + if o == nil || o.Stacks == nil { + return ErrRequiredStacksList + } + return nil +} diff --git a/variable_set_test.go b/variable_set_test.go index 4c923c1f8..d7a8fffcd 100644 --- a/variable_set_test.go +++ b/variable_set_test.go @@ -564,6 +564,146 @@ func TestVariableSetsApplyToAndRemoveFromProjects(t *testing.T) { }) } +func TestVariableSetsApplyToAndRemoveFromStacks(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + vsTest, vsTestCleanup := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + t.Cleanup(vsTestCleanup) + + oauthClient, cleanup := createOAuthClient(t, client, orgTest, nil) + t.Cleanup(cleanup) + + stackTest1, err := client.Stacks.Create(ctx, StackCreateOptions{ + Name: "test-stack-1", + VCSRepo: &StackVCSRepoOptions{ + Identifier: "hashicorp-guides/pet-nulls-stack", + OAuthTokenID: oauthClient.OAuthTokens[0].ID, + }, + Project: &Project{ + ID: orgTest.DefaultProject.ID, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + if err := client.Stacks.Delete(ctx, stackTest1.ID); err != nil { + t.Logf("Failed to cleanup stack %s: %v", stackTest1.ID, err) + } + }) + + // Wait for stack to be ready by triggering configuration update + _, err = client.Stacks.FetchLatestFromVcs(ctx, stackTest1.ID) + require.NoError(t, err) + + stackTest2, err := client.Stacks.Create(ctx, StackCreateOptions{ + Name: "test-stack-2", + VCSRepo: &StackVCSRepoOptions{ + Identifier: "hashicorp-guides/pet-nulls-stack", + OAuthTokenID: oauthClient.OAuthTokens[0].ID, + }, + Project: &Project{ + ID: orgTest.DefaultProject.ID, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + if err := client.Stacks.Delete(ctx, stackTest2.ID); err != nil { + t.Logf("Failed to cleanup stack %s: %v", stackTest2.ID, err) + } + }) + + // Wait for stack to be ready by triggering configuration update + _, err = client.Stacks.FetchLatestFromVcs(ctx, stackTest2.ID) + // Don't require this to succeed as it might not be needed + + t.Run("with first stack added", func(t *testing.T) { + options := VariableSetApplyToStacksOptions{ + Stacks: []*Stack{{ID: stackTest1.ID}}, + } + err = client.VariableSets.ApplyToStacks(ctx, vsTest.ID, &options) + require.NoError(t, err) + + readOpts := &VariableSetReadOptions{Include: &[]VariableSetIncludeOpt{VariableSetStacks}} + vsAfter, err := client.VariableSets.Read(ctx, vsTest.ID, readOpts) + require.NoError(t, err) + + assert.Equal(t, 1, len(vsAfter.Stacks)) + assert.Equal(t, stackTest1.ID, vsAfter.Stacks[0].ID) + }) + + t.Run("with second stack added", func(t *testing.T) { + options := VariableSetApplyToStacksOptions{ + Stacks: []*Stack{stackTest2}, + } + + err := client.VariableSets.ApplyToStacks(ctx, vsTest.ID, &options) + require.NoError(t, err) + + readOpts := &VariableSetReadOptions{Include: &[]VariableSetIncludeOpt{VariableSetStacks}} + vsAfter, err := client.VariableSets.Read(ctx, vsTest.ID, readOpts) + require.NoError(t, err) + + assert.Equal(t, 2, len(vsAfter.Stacks)) + stackIDs := []string{vsAfter.Stacks[0].ID, vsAfter.Stacks[1].ID} + + assert.Contains(t, stackIDs, stackTest1.ID) + assert.Contains(t, stackIDs, stackTest2.ID) + }) + + t.Run("with first stack removed", func(t *testing.T) { + options := VariableSetRemoveFromStacksOptions{ + Stacks: []*Stack{stackTest1}, + } + + err := client.VariableSets.RemoveFromStacks(ctx, vsTest.ID, &options) + require.NoError(t, err) + + readOpts := &VariableSetReadOptions{Include: &[]VariableSetIncludeOpt{VariableSetStacks}} + vsAfter, err := client.VariableSets.Read(ctx, vsTest.ID, readOpts) + require.NoError(t, err) + + assert.Equal(t, 1, len(vsAfter.Stacks)) + assert.Equal(t, stackTest2.ID, vsAfter.Stacks[0].ID) + }) + + t.Run("when variable set ID is invalid", func(t *testing.T) { + applyOptions := VariableSetApplyToStacksOptions{ + Stacks: []*Stack{stackTest1}, + } + err := client.VariableSets.ApplyToStacks(ctx, badIdentifier, &applyOptions) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + + removeOptions := VariableSetRemoveFromStacksOptions{ + Stacks: []*Stack{stackTest1}, + } + err = client.VariableSets.RemoveFromStacks(ctx, badIdentifier, &removeOptions) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) + + t.Run("when stack ID is invalid", func(t *testing.T) { + badStack := &Stack{ + ID: badIdentifier, + } + + applyOptions := VariableSetApplyToStacksOptions{ + Stacks: []*Stack{badStack}, + } + err := client.VariableSets.ApplyToStacks(ctx, vsTest.ID, &applyOptions) + assert.EqualError(t, err, ErrRequiredStackID.Error()) + + removeOptions := VariableSetRemoveFromStacksOptions{ + Stacks: []*Stack{badStack}, + } + err = client.VariableSets.RemoveFromStacks(ctx, vsTest.ID, &removeOptions) + assert.EqualError(t, err, ErrRequiredStackID.Error()) + }) +} + func TestVariableSetsUpdateWorkspaces(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -598,3 +738,69 @@ func TestVariableSetsUpdateWorkspaces(t *testing.T) { assert.Equal(t, len(options.Workspaces), len(vsAfter.Workspaces)) }) } + +func TestVariableSetsUpdateStacks(t *testing.T) { + skipUnlessBeta(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + vsTest, vsTestCleanup := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + t.Cleanup(vsTestCleanup) + + oauthClient, cleanup := createOAuthClient(t, client, orgTest, nil) + t.Cleanup(cleanup) + + stackTest, err := client.Stacks.Create(ctx, StackCreateOptions{ + Name: "test-stack", + VCSRepo: &StackVCSRepoOptions{ + Identifier: "hashicorp-guides/pet-nulls-stack", + OAuthTokenID: oauthClient.OAuthTokens[0].ID, + }, + Project: &Project{ + ID: orgTest.DefaultProject.ID, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + if err := client.Stacks.Delete(ctx, stackTest.ID); err != nil { + t.Logf("Failed to cleanup stack %s: %v", stackTest.ID, err) + } + }) + + // Wait for stack to be ready by triggering configuration update + _, err = client.Stacks.FetchLatestFromVcs(ctx, stackTest.ID) + require.NoError(t, err) + + t.Run("with valid stacks", func(t *testing.T) { + options := VariableSetUpdateStacksOptions{ + Stacks: []*Stack{stackTest}, + } + + _, err := client.VariableSets.UpdateStacks(ctx, vsTest.ID, &options) + require.NoError(t, err) + + readOpts := &VariableSetReadOptions{Include: &[]VariableSetIncludeOpt{VariableSetStacks}} + vsAfter, err := client.VariableSets.Read(ctx, vsTest.ID, readOpts) + require.NoError(t, err) + + assert.Equal(t, len(options.Stacks), len(vsAfter.Stacks)) + assert.Equal(t, options.Stacks[0].ID, vsAfter.Stacks[0].ID) + + options = VariableSetUpdateStacksOptions{ + Stacks: []*Stack{}, + } + + _, err = client.VariableSets.UpdateStacks(ctx, vsTest.ID, &options) + require.NoError(t, err) + + readOpts = &VariableSetReadOptions{Include: &[]VariableSetIncludeOpt{VariableSetStacks}} + vsAfter, err = client.VariableSets.Read(ctx, vsTest.ID, readOpts) + require.NoError(t, err) + + assert.Equal(t, len(options.Stacks), len(vsAfter.Stacks)) + }) +}