diff --git a/errors.go b/errors.go index 466514661..46cb387b4 100644 --- a/errors.go +++ b/errors.go @@ -146,6 +146,8 @@ var ( ErrInvalidNotificationTrigger = errors.New("invalid value for notification trigger") + ErrInvalidVariableSetID = errors.New("invalid variable set ID") + ErrInvalidCommentID = errors.New("invalid value for comment ID") ErrInvalidCommentBody = errors.New("invalid value for comment body") @@ -253,5 +255,9 @@ var ( ErrRequiredUsernameOrMembershipIds = errors.New("usernames or organization membership ids are required") + ErrRequiredGlobalFlag = errors.New("global flag is required") + + ErrRequiredWorkspacesList = errors.New("no workspaces list provided") + ErrCommentBody = errors.New("comment body is required") ) diff --git a/helper_test.go b/helper_test.go index 0b049c9ff..cfb96f2c2 100644 --- a/helper_test.go +++ b/helper_test.go @@ -1077,6 +1077,90 @@ func createWorkspaceRunTask(t *testing.T, client *Client, workspace *Workspace, } } +func createVariableSet(t *testing.T, client *Client, org *Organization, options VariableSetCreateOptions) (*VariableSet, func()) { + var orgCleanup func() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + if options.Name == nil { + options.Name = String(randomString(t)) + } + + if options.Global == nil { + options.Global = Bool(false) + } + + ctx := context.Background() + vs, err := client.VariableSets.Create(ctx, org.Name, &options) + if err != nil { + t.Fatal(err) + } + + return vs, func() { + if err := client.VariableSets.Delete(ctx, vs.ID); err != nil { + t.Errorf("Error destroying variable set! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "VariableSet: %s\nError: %s", vs.Name, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func createVariableSetVariable(t *testing.T, client *Client, vs *VariableSet, options VariableSetVariableCreateOptions) (*VariableSetVariable, func()) { + var vsCleanup func() + + if vs == nil { + vs, vsCleanup = createVariableSet(t, client, nil, VariableSetCreateOptions{}) + } + + if options.Key == nil { + options.Key = String(randomString(t)) + } + + if options.Value == nil { + options.Value = String(randomString(t)) + } + + if options.Description == nil { + options.Description = String("") + } + + if options.Category == nil { + options.Category = Category(CategoryTerraform) + } + + if options.HCL == nil { + options.HCL = Bool(false) + } + + if options.Sensitive == nil { + options.Sensitive = Bool(false) + } + + ctx := context.Background() + v, err := client.VariableSetVariables.Create(ctx, vs.ID, &options) + if err != nil { + t.Fatal(err) + } + + return v, func() { + if err := client.VariableSetVariables.Delete(ctx, vs.ID, v.ID); err != nil { + t.Errorf("Error destroying variable! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Variable: %s\nError: %s", v.Key, err) + } + + if vsCleanup != nil { + vsCleanup() + } + } +} + func genSha(t *testing.T, secret, data string) string { h := hmac.New(sha256.New, []byte(secret)) _, err := h.Write([]byte(data)) diff --git a/tfe.go b/tfe.go index f5ec590b1..cbc88459f 100644 --- a/tfe.go +++ b/tfe.go @@ -143,6 +143,8 @@ type Client struct { Users Users UserTokens UserTokens Variables Variables + VariableSets VariableSets + VariableSetVariables VariableSetVariables Workspaces Workspaces WorkspaceRunTasks WorkspaceRunTasks @@ -284,6 +286,8 @@ func NewClient(cfg *Config) (*Client, error) { client.Users = &users{client: client} client.UserTokens = &userTokens{client: client} client.Variables = &variables{client: client} + client.VariableSets = &variableSets{client: client} + client.VariableSetVariables = &variableSetVariables{client: client} client.Workspaces = &workspaces{client: client} client.WorkspaceRunTasks = &workspaceRunTasks{client: client} diff --git a/variable_integration_test.go b/variable_integration_test.go index 1393c0a9c..90c0b5722 100644 --- a/variable_integration_test.go +++ b/variable_integration_test.go @@ -128,7 +128,7 @@ func TestVariablesCreate(t *testing.T) { options := VariableCreateOptions{ Key: String(randomString(t)), Value: String(randomString(t)), - Description: String("tortor aliquam nulla facilisi cras fermentum odio eu feugiat pretium nibh ipsum consequat nisl vel pretium lectus quam id leo in vitae turpis massa sed elementum tempus egestas sed sed risus pretium quam vulputate dignissim suspendisse in est ante in nibh mauris cursus mattis molestie a iaculis at erat pellentesque adipiscing commodo elit at imperdiet dui accumsan sit amet nulla facilisi morbi tempus iaculis urna id volutpat lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis aenean et tortor"), + Description: String("tortor aliquam nulla go lint is fussy about spelling cras fermentum odio eu feugiat pretium nibh ipsum consequat nisl vel pretium lectus quam id leo in vitae turpis massa sed elementum tempus egestas sed sed risus pretium quam vulputate dignissim suspendisse in est ante in nibh mauris cursus mattis molestie a iaculis at erat pellentesque adipiscing commodo elit at imperdiet dui accumsan sit amet nulla redacted morbi tempus iaculis urna id volutpat lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis aenean et tortor"), Category: Category(CategoryTerraform), } diff --git a/variable_set.go b/variable_set.go new file mode 100644 index 000000000..1e2e438f2 --- /dev/null +++ b/variable_set.go @@ -0,0 +1,289 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ VariableSets = (*variableSets)(nil) + +// VariableSets describes all the Variable Set related methods that the +// Terraform Enterprise API supports. +// +// TFE API docs: https://www.terraform.io/cloud-docs/api-docs/variable-sets +type VariableSets interface { + // List all the variable sets within an organization. + List(ctx context.Context, organization string, options *VariableSetListOptions) (*VariableSetList, error) + + // Create is used to create a new variable set. + Create(ctx context.Context, organization string, options *VariableSetCreateOptions) (*VariableSet, error) + + // Read a variable set by its ID. + Read(ctx context.Context, variableSetID string, options *VariableSetReadOptions) (*VariableSet, error) + + // Update an existing variable set. + Update(ctx context.Context, variableSetID string, options *VariableSetUpdateOptions) (*VariableSet, error) + + // Delete a variable set by ID. + Delete(ctx context.Context, variableSetID string) 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) +} + +type variableSets struct { + client *Client +} + +type VariableSetList struct { + *Pagination + Items []*VariableSet +} + +type VariableSet struct { + ID string `jsonapi:"primary,varsets"` + Name string `jsonapi:"attr,name"` + Description string `jsonapi:"attr,description"` + Global bool `jsonapi:"attr,global"` + + // Relations + Organization *Organization `jsonapi:"relation,organization"` + Workspaces []*Workspace `jsonapi:"relation,workspaces,omitempty"` + Variables []*VariableSetVariable `jsonapi:"relation,vars,omitempty"` +} + +// A list of relations to include. See available resources +// https://www.terraform.io/docs/cloud/api/admin/organizations.html#available-related-resources +type VariableSetIncludeOpt string + +const ( + VariableSetWorkspaces VariableSetIncludeOpt = "workspaces" + VariableSetVars VariableSetIncludeOpt = "vars" +) + +type VariableSetListOptions struct { + ListOptions + Include string `url:"include"` +} + +func (o *VariableSetListOptions) valid() error { + return nil +} + +// List all Variable Sets in the organization +func (s *variableSets) List(ctx context.Context, organization string, options *VariableSetListOptions) (*VariableSetList, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + if options != nil { + if err := options.valid(); err != nil { + return nil, err + } + } + + u := fmt.Sprintf("organizations/%s/varsets", url.QueryEscape(organization)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + vl := &VariableSetList{} + err = s.client.do(ctx, req, vl) + if err != nil { + return nil, err + } + + return vl, nil +} + +// VariableSetCreateOptions represents the options for creating a new variable set within in a organization. +type VariableSetCreateOptions 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 name of the variable set. + // Affects variable precedence when there are conflicts between Variable Sets + // https://www.terraform.io/cloud-docs/api-docs/variable-sets#apply-variable-set-to-workspaces + Name *string `jsonapi:"attr,name"` + + // A description to provide context for the variable set. + Description *string `jsonapi:"attr,description,omitempty"` + + // If true the variable set is considered in all runs in the organization. + Global *bool `jsonapi:"attr,global,omitempty"` +} + +func (o *VariableSetCreateOptions) valid() error { + if o == nil { + return nil + } + if !validString(o.Name) { + return ErrRequiredName + } + if o.Global == nil { + return ErrRequiredGlobalFlag + } + return nil +} + +// Create is used to create a new variable set. +func (s *variableSets) Create(ctx context.Context, organization string, options *VariableSetCreateOptions) (*VariableSet, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + if err := options.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("organizations/%s/varsets", url.QueryEscape(organization)) + req, err := s.client.newRequest("POST", u, options) + if err != nil { + return nil, err + } + + vl := &VariableSet{} + err = s.client.do(ctx, req, vl) + if err != nil { + return nil, err + } + + return vl, nil +} + +type VariableSetReadOptions struct { + Include *[]VariableSetIncludeOpt `url:"include:omitempty"` +} + +// Read is used to inspect a given variable set based on ID +func (s *variableSets) Read(ctx context.Context, variableSetID string, options *VariableSetReadOptions) (*VariableSet, error) { + if !validStringID(&variableSetID) { + return nil, ErrInvalidVariableSetID + } + + u := fmt.Sprintf("varsets/%s", url.QueryEscape(variableSetID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + vs := &VariableSet{} + err = s.client.do(ctx, req, vs) + if err != nil { + return nil, err + } + + return vs, err +} + +// VariableSetUpdateOptions represents the options for updating a variable set. +type VariableSetUpdateOptions 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 name of the variable set. + // Affects variable precedence when there are conflicts between Variable Sets + // https://www.terraform.io/cloud-docs/api-docs/variable-sets#apply-variable-set-to-workspaces + Name *string `jsonapi:"attr,name,omitempty"` + + // A description to provide context for the variable set. + Description *string `jsonapi:"attr,description,omitempty"` + + // If true the variable set is considered in all runs in the organization. + Global *bool `jsonapi:"attr,global,omitempty"` +} + +func (s *variableSets) Update(ctx context.Context, variableSetID string, options *VariableSetUpdateOptions) (*VariableSet, error) { + if !validStringID(&variableSetID) { + return nil, ErrInvalidVariableSetID + } + + u := fmt.Sprintf("varsets/%s", url.QueryEscape(variableSetID)) + req, err := s.client.newRequest("PATCH", u, options) + if err != nil { + return nil, err + } + + v := &VariableSet{} + err = s.client.do(ctx, req, v) + if err != nil { + return nil, err + } + + return v, nil +} + +// Delete a variable set by its ID. +func (s *variableSets) Delete(ctx context.Context, variableSetID string) error { + if !validStringID(&variableSetID) { + return ErrInvalidVariableSetID + } + + u := fmt.Sprintf("varsets/%s", url.QueryEscape(variableSetID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} + +// 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 + // 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 workspaces to be applied to. An empty set means remove all applied + Workspaces []*Workspace `jsonapi:"relation,workspaces"` +} + +func (o *VariableSetUpdateWorkspacesOptions) valid() error { + if o == nil || o.Workspaces == nil { + return ErrRequiredWorkspacesList + } + return nil +} + +type privateVariableSetUpdateWorkspacesOptions struct { + Type string `jsonapi:"primary,varsets"` + Global bool `jsonapi:"attr,global"` + Workspaces []*Workspace `jsonapi:"relation,workspaces"` +} + +// 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 { + return nil, err + } + + // Use private struct to ensure global is set to false when applying to workspaces + o := privateVariableSetUpdateWorkspacesOptions{ + Global: bool(false), + Workspaces: options.Workspaces, + } + + // We force inclusion of workspaces as that is the primary data for which we are concerned with confirming changes. + u := fmt.Sprintf("varsets/%s?include=%s", url.QueryEscape(variableSetID), VariableSetWorkspaces) + req, err := s.client.newRequest("PATCH", u, &o) + if err != nil { + return nil, err + } + + v := &VariableSet{} + err = s.client.do(ctx, req, v) + if err != nil { + return nil, err + } + + return v, nil +} diff --git a/variable_set_test.go b/variable_set_test.go new file mode 100644 index 000000000..0290661bc --- /dev/null +++ b/variable_set_test.go @@ -0,0 +1,224 @@ +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVariableSetsList(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest1, vsTestCleanup1 := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + defer vsTestCleanup1() + vsTest2, vsTestCleanup2 := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + defer vsTestCleanup2() + + t.Run("without list options", func(t *testing.T) { + vsl, err := client.VariableSets.List(ctx, orgTest.Name, nil) + require.NoError(t, err) + assert.Contains(t, vsl.Items, vsTest1) + assert.Contains(t, vsl.Items, vsTest2) + + t.Skip("paging not supported yet in API") + assert.Equal(t, 1, vsl.CurrentPage) + assert.Equal(t, 2, vsl.TotalCount) + }) + + t.Run("with list options", func(t *testing.T) { + t.Skip("paging not supported yet in API") + // Request a page number which is out of range. The result should + // be successful, but return no results if the paging options are + // properly passed along. + vsl, err := client.VariableSets.List(ctx, orgTest.Name, &VariableSetListOptions{ + ListOptions: ListOptions{ + PageNumber: 999, + PageSize: 100, + }, + }) + require.NoError(t, err) + assert.Empty(t, vsl.Items) + assert.Equal(t, 999, vsl.CurrentPage) + assert.Equal(t, 2, vsl.TotalCount) + }) + + t.Run("when Organization name is invalid ID", func(t *testing.T) { + vsl, err := client.VariableSets.List(ctx, badIdentifier, nil) + assert.Nil(t, vsl) + assert.EqualError(t, err, ErrInvalidOrg.Error()) + }) +} + +func TestVariableSetsCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + t.Run("with valid options", func(t *testing.T) { + options := VariableSetCreateOptions{ + Name: String("varset"), + Description: String("a variable set"), + Global: Bool(false), + } + + vs, err := client.VariableSets.Create(ctx, orgTest.Name, &options) + require.NoError(t, err) + + // Get refreshed view from the API + refreshed, err := client.VariableSets.Read(ctx, vs.ID, nil) + require.NoError(t, err) + + for _, item := range []*VariableSet{ + vs, + refreshed, + } { + assert.NotEmpty(t, item.ID) + assert.Equal(t, *options.Name, item.Name) + assert.Equal(t, *options.Description, item.Description) + assert.Equal(t, *options.Global, item.Global) + } + }) + + t.Run("when options is missing name", func(t *testing.T) { + vs, err := client.VariableSets.Create(ctx, "foo", &VariableSetCreateOptions{ + Global: Bool(true), + }) + assert.Nil(t, vs) + assert.EqualError(t, err, ErrRequiredName.Error()) + }) + + t.Run("when options is missing global flag", func(t *testing.T) { + vs, err := client.VariableSets.Create(ctx, "foo", &VariableSetCreateOptions{ + Name: String("foo"), + }) + assert.Nil(t, vs) + assert.EqualError(t, err, ErrRequiredGlobalFlag.Error()) + }) +} + +func TestVariableSetsRead(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, vsTestCleanup := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + defer vsTestCleanup() + + t.Run("when the variable set exists", func(t *testing.T) { + vs, err := client.VariableSets.Read(ctx, vsTest.ID, nil) + require.NoError(t, err) + assert.Equal(t, vsTest, vs) + }) + + t.Run("when variable set does not exist", func(t *testing.T) { + vs, err := client.VariableSets.Read(ctx, "nonexisting", nil) + assert.Nil(t, vs) + assert.Error(t, err) + }) +} + +func TestVariableSetsUpdate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, _ := createVariableSet(t, client, orgTest, VariableSetCreateOptions{ + Name: String("OriginalName"), + Description: String("Original Description"), + Global: Bool(false), + }) + + t.Run("when updating a subset of values", func(t *testing.T) { + options := VariableSetUpdateOptions{ + Name: String("UpdatedName"), + Description: String("Updated Description"), + Global: Bool(true), + } + + vsAfter, err := client.VariableSets.Update(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, *options.Name, vsAfter.Name) + assert.Equal(t, *options.Description, vsAfter.Description) + assert.Equal(t, *options.Global, vsAfter.Global) + }) + + t.Run("when options has an invalid variable set ID", func(t *testing.T) { + vsAfter, err := client.VariableSets.Update(ctx, badIdentifier, &VariableSetUpdateOptions{ + Name: String("UpdatedName"), + Description: String("Updated Description"), + Global: Bool(true), + }) + assert.Nil(t, vsAfter) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) +} + +func TestVariableSetsDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, _ := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + + t.Run("with valid ID", func(t *testing.T) { + err := client.VariableSets.Delete(ctx, vsTest.ID) + require.NoError(t, err) + + // Try loading the variable set - it should fail. + _, err = client.VariableSets.Read(ctx, vsTest.ID, nil) + assert.Equal(t, ErrResourceNotFound, err) + }) + + t.Run("when ID is invalid", func(t *testing.T) { + err := client.VariableSets.Delete(ctx, badIdentifier) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) +} + +func TestVariableSetsUpdateWorkspaces(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, _ := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + + wTest, _ := createWorkspace(t, client, orgTest) + + t.Run("with valid workspaces", func(t *testing.T) { + options := VariableSetUpdateWorkspacesOptions{ + Workspaces: []*Workspace{wTest}, + } + + vsAfter, err := client.VariableSets.UpdateWorkspaces(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, len(options.Workspaces), len(vsAfter.Workspaces)) + assert.Equal(t, options.Workspaces[0].ID, vsAfter.Workspaces[0].ID) + + options = VariableSetUpdateWorkspacesOptions{ + Workspaces: []*Workspace{}, + } + + vsAfter, err = client.VariableSets.UpdateWorkspaces(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, len(options.Workspaces), len(vsAfter.Workspaces)) + }) +} diff --git a/variable_set_variable.go b/variable_set_variable.go new file mode 100644 index 000000000..dea5835ff --- /dev/null +++ b/variable_set_variable.go @@ -0,0 +1,241 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ VariableSetVariables = (*variableSetVariables)(nil) + +// VariableSetVariables describes all variable variable related methods within the scope of +// Variable Sets that the Terraform Enterprise API supports +// +// TFE API docs: https://www.terraform.io/cloud-docs/api-docs/variable-sets#variable-relationships +type VariableSetVariables interface { + // List all variables in the variable set. + List(ctx context.Context, variableSetID string, options *VariableSetVariableListOptions) (*VariableSetVariableList, error) + + // Create is used to create a new variable within a given variable set + Create(ctx context.Context, variableSetID string, options *VariableSetVariableCreateOptions) (*VariableSetVariable, error) + + // Read a variable by its ID + Read(ctx context.Context, variableSetID string, variableID string) (*VariableSetVariable, error) + + // Update valuse of an existing variable + Update(ctx context.Context, variableSetID string, variableID string, options *VariableSetVariableUpdateOptions) (*VariableSetVariable, error) + + // Delete a variable by its ID + Delete(ctx context.Context, variableSetID string, variableID string) error +} + +type variableSetVariables struct { + client *Client +} + +type VariableSetVariableList struct { + *Pagination + Items []*VariableSetVariable +} + +type VariableSetVariable struct { + ID string `jsonapi:"primary,vars"` + Key string `jsonapi:"attr,key"` + Value string `jsonapi:"attr,value"` + Description string `jsonapi:"attr,description"` + Category CategoryType `jsonapi:"attr,category"` + HCL bool `jsonapi:"attr,hcl"` + Sensitive bool `jsonapi:"attr,sensitive"` + + // Relations + VariableSet *VariableSet `jsonapi:"relation,configurable"` +} + +type VariableSetVariableListOptions struct { + ListOptions +} + +func (o VariableSetVariableListOptions) valid() error { + return nil +} + +// List all variables associated with the given variable set. +func (s *variableSetVariables) List(ctx context.Context, variableSetID string, options *VariableSetVariableListOptions) (*VariableSetVariableList, error) { + if !validStringID(&variableSetID) { + return nil, ErrInvalidVariableSetID + } + if options != nil { + if err := options.valid(); err != nil { + return nil, err + } + } + + u := fmt.Sprintf("varsets/%s/relationships/vars", url.QueryEscape(variableSetID)) + req, err := s.client.newRequest("GET", u, options) + if err != nil { + return nil, err + } + + vl := &VariableSetVariableList{} + err = s.client.do(ctx, req, vl) + if err != nil { + return nil, err + } + + return vl, nil +} + +// VariableSetVariableCreatOptions represents the options for creating a new variable within a variable set +type VariableSetVariableCreateOptions 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,vars"` + + // The name of the variable. + Key *string `jsonapi:"attr,key"` + + // The value of the variable. + Value *string `jsonapi:"attr,value,omitempty"` + + // The description of the variable. + Description *string `jsonapi:"attr,description,omitempty"` + + // Whether this is a Terraform or environment variable. + Category *CategoryType `jsonapi:"attr,category"` + + // Whether to evaluate the value of the variable as a string of HCL code. + HCL *bool `jsonapi:"attr,hcl,omitempty"` + + // Whether the value is sensitive. + Sensitive *bool `jsonapi:"attr,sensitive,omitempty"` +} + +func (o VariableSetVariableCreateOptions) valid() error { + if !validString(o.Key) { + return ErrRequiredKey + } + if o.Category == nil { + return ErrRequiredCategory + } + return nil +} + +// Create is used to create a new variable. +func (s *variableSetVariables) Create(ctx context.Context, variableSetID string, options *VariableSetVariableCreateOptions) (*VariableSetVariable, error) { + if !validStringID(&variableSetID) { + return nil, ErrInvalidVariableSetID + } + if options != nil { + if err := options.valid(); err != nil { + return nil, err + } + } + + u := fmt.Sprintf("varsets/%s/relationships/vars", url.QueryEscape(variableSetID)) + req, err := s.client.newRequest("POST", u, options) + if err != nil { + return nil, err + } + + v := &VariableSetVariable{} + err = s.client.do(ctx, req, v) + if err != nil { + return nil, err + } + + return v, nil +} + +// Read a variable by its ID. +func (s *variableSetVariables) Read(ctx context.Context, variableSetID, variableID string) (*VariableSetVariable, error) { + if !validStringID(&variableSetID) { + return nil, ErrInvalidVariableSetID + } + if !validStringID(&variableID) { + return nil, ErrInvalidVariableID + } + + u := fmt.Sprintf("varsets/%s/relationships/vars/%s", url.QueryEscape(variableSetID), url.QueryEscape(variableID)) + req, err := s.client.newRequest("GET", u, nil) + + if err != nil { + return nil, err + } + + v := &VariableSetVariable{} + err = s.client.do(ctx, req, v) + if err != nil { + return nil, err + } + + return v, err +} + +// VariableSetVariableUpdateOptions represents the options for updating a variable. +type VariableSetVariableUpdateOptions 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,vars"` + + // The name of the variable. + Key *string `jsonapi:"attr,key,omitempty"` + + // The value of the variable. + Value *string `jsonapi:"attr,value,omitempty"` + + // The description of the variable. + Description *string `jsonapi:"attr,description,omitempty"` + + // Whether to evaluate the value of the variable as a string of HCL code. + HCL *bool `jsonapi:"attr,hcl,omitempty"` + + // Whether the value is sensitive. + Sensitive *bool `jsonapi:"attr,sensitive,omitempty"` +} + +// Update values of an existing variable. +func (s *variableSetVariables) Update(ctx context.Context, variableSetID, variableID string, options *VariableSetVariableUpdateOptions) (*VariableSetVariable, error) { + if !validStringID(&variableSetID) { + return nil, ErrInvalidVariableSetID + } + if !validStringID(&variableID) { + return nil, ErrInvalidVariableID + } + + u := fmt.Sprintf("varsets/%s/relationships/vars/%s", url.QueryEscape(variableSetID), url.QueryEscape(variableID)) + req, err := s.client.newRequest("PATCH", u, options) + if err != nil { + return nil, err + } + + v := &VariableSetVariable{} + err = s.client.do(ctx, req, v) + if err != nil { + return nil, err + } + + return v, nil +} + +// Delete a variable by its ID. +func (s *variableSetVariables) Delete(ctx context.Context, variableSetID, variableID string) error { + if !validStringID(&variableSetID) { + return ErrInvalidVariableSetID + } + if !validStringID(&variableID) { + return ErrInvalidVariableID + } + + u := fmt.Sprintf("varsets/%s/relationships/vars/%s", url.QueryEscape(variableSetID), url.QueryEscape(variableID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/variable_set_variable_test.go b/variable_set_variable_test.go new file mode 100644 index 000000000..cc2107103 --- /dev/null +++ b/variable_set_variable_test.go @@ -0,0 +1,354 @@ +package tfe + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestVariableSetVariablesList(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, vsTestCleanup := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + defer vsTestCleanup() + + vTest1, vTestCleanup1 := createVariableSetVariable(t, client, vsTest, VariableSetVariableCreateOptions{ + Key: String("vTest1"), + Value: String("vTest1"), + Category: Category(CategoryTerraform), + }) + defer vTestCleanup1() + vTest2, vTestCleanup2 := createVariableSetVariable(t, client, vsTest, VariableSetVariableCreateOptions{ + Key: String("vTest2"), + Value: String("vTest2"), + Category: Category(CategoryTerraform), + }) + defer vTestCleanup2() + + t.Run("without list options", func(t *testing.T) { + vl, err := client.VariableSetVariables.List(ctx, vsTest.ID, nil) + require.NoError(t, err) + assert.Contains(t, vl.Items, vTest1) + assert.Contains(t, vl.Items, vTest2) + + t.Skip("paging not supported yet in API") + assert.Equal(t, 1, vl.CurrentPage) + assert.Equal(t, 2, vl.TotalCount) + }) + + t.Run("with list options", func(t *testing.T) { + t.Skip("paging not supported yet in API") + // Request a page number which is out of range. The result should + // be successful, but return no results if the paging options are + // properly passed along. + vl, err := client.VariableSetVariables.List(ctx, vsTest.ID, &VariableSetVariableListOptions{ + ListOptions: ListOptions{ + PageNumber: 999, + PageSize: 100, + }, + }) + require.NoError(t, err) + assert.Empty(t, vl.Items) + assert.Equal(t, 999, vl.CurrentPage) + assert.Equal(t, 2, vl.TotalCount) + }) + + t.Run("when variable set ID is invalid ID", func(t *testing.T) { + vl, err := client.VariableSetVariables.List(ctx, badIdentifier, nil) + assert.Nil(t, vl) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) +} + +func TestVariableSetVariablesCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, vsTestCleanup := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + defer vsTestCleanup() + + t.Run("with valid options", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Value: String(randomString(t)), + Category: Category(CategoryTerraform), + Description: String(randomString(t)), + HCL: Bool(false), + Sensitive: Bool(false), + } + + v, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.NotEmpty(t, v.ID) + assert.Equal(t, *options.Key, v.Key) + assert.Equal(t, *options.Value, v.Value) + assert.Equal(t, *options.Description, v.Description) + assert.Equal(t, *options.Category, v.Category) + }) + + t.Run("when options has an empty string value", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Value: String(""), + Description: String(randomString(t)), + Category: Category(CategoryTerraform), + } + + v, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.NotEmpty(t, v.ID) + assert.Equal(t, *options.Key, v.Key) + assert.Equal(t, *options.Value, v.Value) + assert.Equal(t, *options.Description, v.Description) + assert.Equal(t, *options.Category, v.Category) + }) + + t.Run("when options has an empty string description", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Value: String(randomString(t)), + Description: String(""), + Category: Category(CategoryTerraform), + } + + v, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.NotEmpty(t, v.ID) + assert.Equal(t, *options.Key, v.Key) + assert.Equal(t, *options.Value, v.Value) + assert.Equal(t, *options.Description, v.Description) + assert.Equal(t, *options.Category, v.Category) + }) + + t.Run("when options has a too-long description", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Value: String(randomString(t)), + Description: String("tortor aliquam nulla redacted cras fermentum odio eu feugiat pretium nibh ipsum consequat nisl vel pretium lectus quam id leo in vitae turpis massa sed elementum tempus egestas sed sed risus pretium quam vulputate dignissim suspendisse in est ante in nibh mauris cursus mattis molestie a iaculis at erat pellentesque adipiscing commodo elit at imperdiet dui accumsan sit amet nulla redacted morbi tempus iaculis urna id volutpat lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis aenean et tortor"), + Category: Category(CategoryTerraform), + } + + _, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + assert.Error(t, err) + }) + + t.Run("when options is missing value", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Category: Category(CategoryTerraform), + } + + v, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + require.NoError(t, err) + + assert.NotEmpty(t, v.ID) + assert.Equal(t, *options.Key, v.Key) + assert.Equal(t, "", v.Value) + assert.Equal(t, *options.Category, v.Category) + }) + + t.Run("when options is missing key", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Value: String(randomString(t)), + Category: Category(CategoryTerraform), + } + + _, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + assert.EqualError(t, err, ErrRequiredKey.Error()) + }) + + t.Run("when options has an empty key", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(""), + Value: String(randomString(t)), + Category: Category(CategoryTerraform), + } + + _, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + assert.EqualError(t, err, ErrRequiredKey.Error()) + }) + + t.Run("when options is missing category", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Value: String(randomString(t)), + } + + _, err := client.VariableSetVariables.Create(ctx, vsTest.ID, &options) + assert.EqualError(t, err, ErrRequiredCategory.Error()) + }) + + t.Run("when workspace ID is invalid", func(t *testing.T) { + options := VariableSetVariableCreateOptions{ + Key: String(randomString(t)), + Value: String(randomString(t)), + Category: Category(CategoryTerraform), + } + + _, err := client.VariableSetVariables.Create(ctx, badIdentifier, &options) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) +} + +func TestVariableSetVariablesRead(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + vsTest, vsTestCleanup := createVariableSet(t, client, orgTest, VariableSetCreateOptions{}) + defer vsTestCleanup() + + vTest, vTestCleanup := createVariableSetVariable(t, client, vsTest, VariableSetVariableCreateOptions{}) + defer vTestCleanup() + + t.Run("when the variable exists", func(t *testing.T) { + v, err := client.VariableSetVariables.Read(ctx, vsTest.ID, vTest.ID) + require.NoError(t, err) + assert.Equal(t, vTest.ID, v.ID) + assert.Equal(t, vTest.Category, v.Category) + assert.Equal(t, vTest.HCL, v.HCL) + assert.Equal(t, vTest.Key, v.Key) + assert.Equal(t, vTest.Sensitive, v.Sensitive) + assert.Equal(t, vTest.Value, v.Value) + }) + + t.Run("when the variable does not exist", func(t *testing.T) { + v, err := client.VariableSetVariables.Read(ctx, vsTest.ID, "nonexisting") + assert.Nil(t, v) + assert.Equal(t, ErrResourceNotFound, err) + }) + + t.Run("without a valid variable set ID", func(t *testing.T) { + v, err := client.VariableSetVariables.Read(ctx, badIdentifier, vTest.ID) + assert.Nil(t, v) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) + + t.Run("without a valid variable ID", func(t *testing.T) { + v, err := client.VariableSetVariables.Read(ctx, vsTest.ID, badIdentifier) + assert.Nil(t, v) + assert.EqualError(t, err, ErrInvalidVariableID.Error()) + }) +} + +func TestVariableSetVariablesUpdate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + vsTest, vsTestCleanup := createVariableSet(t, client, nil, VariableSetCreateOptions{}) + defer vsTestCleanup() + + vTest, vTestCleanup := createVariableSetVariable(t, client, vsTest, VariableSetVariableCreateOptions{}) + defer vTestCleanup() + + t.Run("with valid options", func(t *testing.T) { + options := VariableSetVariableUpdateOptions{ + Key: String("newname"), + Value: String("newvalue"), + HCL: Bool(true), + } + + v, err := client.VariableSetVariables.Update(ctx, vsTest.ID, vTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, *options.Key, v.Key) + assert.Equal(t, *options.HCL, v.HCL) + assert.Equal(t, *options.Value, v.Value) + }) + + t.Run("when updating a subset of values", func(t *testing.T) { + options := VariableSetVariableUpdateOptions{ + Key: String("someothername"), + HCL: Bool(false), + } + + v, err := client.VariableSetVariables.Update(ctx, vsTest.ID, vTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, *options.Key, v.Key) + assert.Equal(t, *options.HCL, v.HCL) + }) + + t.Run("with sensitive set", func(t *testing.T) { + options := VariableSetVariableUpdateOptions{ + Sensitive: Bool(true), + } + + v, err := client.VariableSetVariables.Update(ctx, vsTest.ID, vTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, *options.Sensitive, v.Sensitive) + assert.Empty(t, v.Value) // Because its now sensitive + }) + + t.Run("without any changes", func(t *testing.T) { + vTest, vTestCleanup := createVariableSetVariable(t, client, vsTest, VariableSetVariableCreateOptions{}) + defer vTestCleanup() + + options := VariableSetVariableUpdateOptions{ + Key: String(vTest.Key), + Value: String(vTest.Value), + Description: String(vTest.Description), + Sensitive: Bool(vTest.Sensitive), + HCL: Bool(vTest.HCL), + } + + v, err := client.VariableSetVariables.Update(ctx, vsTest.ID, vTest.ID, &options) + require.NoError(t, err) + + assert.Equal(t, vTest, v) + }) + + t.Run("with invalid variable ID", func(t *testing.T) { + _, err := client.VariableSetVariables.Update(ctx, badIdentifier, vTest.ID, nil) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) + + t.Run("with invalid variable ID", func(t *testing.T) { + _, err := client.VariableSetVariables.Update(ctx, vsTest.ID, badIdentifier, nil) + assert.EqualError(t, err, ErrInvalidVariableID.Error()) + }) +} + +func TestVariableSetVariablesDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + vsTest, vsTestCleanup := createVariableSet(t, client, nil, VariableSetCreateOptions{}) + defer vsTestCleanup() + + vTest, _ := createVariableSetVariable(t, client, vsTest, VariableSetVariableCreateOptions{}) + + t.Run("with valid options", func(t *testing.T) { + err := client.VariableSetVariables.Delete(ctx, vsTest.ID, vTest.ID) + assert.NoError(t, err) + }) + + t.Run("with non existing variable ID", func(t *testing.T) { + err := client.VariableSetVariables.Delete(ctx, vsTest.ID, "nonexisting") + assert.EqualError(t, err, ErrResourceNotFound.Error()) + }) + + t.Run("with invalid workspace ID", func(t *testing.T) { + err := client.VariableSetVariables.Delete(ctx, badIdentifier, vTest.ID) + assert.EqualError(t, err, ErrInvalidVariableSetID.Error()) + }) + + t.Run("with invalid variable ID", func(t *testing.T) { + err := client.VariableSetVariables.Delete(ctx, vsTest.ID, badIdentifier) + assert.EqualError(t, err, ErrInvalidVariableID.Error()) + }) +}