diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index d92e1fa8c..9d7d2e5fc 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -26,6 +26,7 @@ import ( "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" backendLocal "github.com/hashicorp/terraform/internal/backend/local" ) @@ -104,17 +105,17 @@ func (b *Cloud) ConfigSchema() *configschema.Block { "hostname": { Type: cty.String, Optional: true, - Description: schemaDescriptions["hostname"], + Description: schemaDescriptionHostname, }, "organization": { Type: cty.String, Required: true, - Description: schemaDescriptions["organization"], + Description: schemaDescriptionOrganization, }, "token": { Type: cty.String, Optional: true, - Description: schemaDescriptions["token"], + Description: schemaDescriptionToken, }, }, @@ -125,12 +126,17 @@ func (b *Cloud) ConfigSchema() *configschema.Block { "name": { Type: cty.String, Optional: true, - Description: schemaDescriptions["name"], + Description: schemaDescriptionName, }, "prefix": { Type: cty.String, Optional: true, - Description: schemaDescriptions["prefix"], + Description: schemaDescriptionPrefix, + }, + "tags": { + Type: cty.Set(cty.String), + Optional: true, + Description: schemaDescriptionTags, }, }, }, @@ -159,6 +165,12 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { if val := workspaces.GetAttr("prefix"); !val.IsNull() { workspaceMapping.prefix = val.AsString() } + if val := workspaces.GetAttr("tags"); !val.IsNull() { + err := gocty.FromCtyValue(val, &workspaceMapping.tags) + if err != nil { + log.Panicf("An unxpected error occurred: %s", err) + } + } } switch workspaceMapping.strategy() { @@ -328,6 +340,15 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { if val := workspaces.GetAttr("prefix"); !val.IsNull() { b.workspaceMapping.prefix = val.AsString() } + if val := workspaces.GetAttr("tags"); !val.IsNull() { + var tags []string + err := gocty.FromCtyValue(val, &tags) + if err != nil { + log.Panicf("An unxpected error occurred: %s", err) + } + + b.workspaceMapping.tags = tags + } } // Determine if we are forced to use the local backend. @@ -526,6 +547,9 @@ func (b *Cloud) workspaces() ([]string, error) { options.Search = tfe.String(b.workspaceMapping.name) case workspacePrefixStrategy: options.Search = tfe.String(b.workspaceMapping.prefix) + case workspaceTagsStrategy: + taglist := strings.Join(b.workspaceMapping.tags, ",") + options.Tags = &taglist } // Create a slice to contain all the names. @@ -551,7 +575,7 @@ func (b *Cloud) workspaces() ([]string, error) { } default: // Pass-through. "name" and "prefix" strategies are naive and do - // client-side filtering above, but for any other future + // client-side filtering above, but for tags and any other future // strategy this filtering should be left to the API. names = append(names, w.Name) } @@ -627,6 +651,13 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { Name: tfe.String(name), } + var tags []*tfe.Tag + for _, tag := range b.workspaceMapping.tags { + t := tfe.Tag{Name: tag} + tags = append(tags, &t) + } + options.Tags = tags + // We only set the Terraform Version for the new workspace if this is // a release candidate or a final release. if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { @@ -985,11 +1016,13 @@ func (b *Cloud) cliColorize() *colorstring.Colorize { type workspaceMapping struct { name string prefix string + tags []string } type workspaceStrategy string const ( + workspaceTagsStrategy workspaceStrategy = "tags" workspaceNameStrategy workspaceStrategy = "name" workspacePrefixStrategy workspaceStrategy = "prefix" workspaceNoneStrategy workspaceStrategy = "none" @@ -998,11 +1031,13 @@ const ( func (wm workspaceMapping) strategy() workspaceStrategy { switch { - case wm.name != "" && wm.prefix == "": + case len(wm.tags) > 0 && wm.name == "" && wm.prefix == "": + return workspaceTagsStrategy + case len(wm.tags) == 0 && wm.name != "" && wm.prefix == "": return workspaceNameStrategy - case wm.name == "" && wm.prefix != "": + case len(wm.tags) == 0 && wm.name == "" && wm.prefix != "": return workspacePrefixStrategy - case wm.name == "" && wm.prefix == "": + case len(wm.tags) == 0 && wm.name == "" && wm.prefix == "": return workspaceNoneStrategy default: // Any other combination is invalid as each strategy is mutually exclusive @@ -1070,22 +1105,33 @@ const operationNotCanceled = ` [reset][red]The remote operation was not cancelled.[reset] ` -var schemaDescriptions = map[string]string{ - "hostname": "The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io for use with Terraform Cloud.", - "organization": "The name of the organization containing the targeted workspace(s).", - "token": "The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not be set,\n" + - "and 'terraform login' used instead; your credentials will then be fetched from your CLI configuration file or configured credential helper.", - "name": "The name of a single Terraform Cloud workspace to be used with this configuration.\n" + - "When configured only the specified workspace can be used. This option conflicts\n" + - "with \"prefix\".", - "prefix": "A name prefix used to select remote Terraform Cloud workspaces to be used for this\n" + - "single configuration. New workspaces will automatically be prefixed with this prefix. This option conflicts with \"name\".", -} +var ( + workspaceConfigurationHelp = fmt.Sprintf( + `The 'workspaces' block configures how Terraform CLI maps its workspaces for this single +configuration to workspaces within a Terraform Cloud organization. Three strategies are available: -var workspaceConfigurationHelp = fmt.Sprintf(`The 'workspaces' block configures how Terraform CLI maps its workspaces for this -single configuration to workspaces within a Terraform Cloud organization. Two strategies are available: +[bold]tags[reset] - %s [bold]name[reset] - %s -[bold]prefix[reset] - %s -`, schemaDescriptions["name"], schemaDescriptions["prefix"]) +[bold]prefix[reset] - %s`, schemaDescriptionTags, schemaDescriptionName, schemaDescriptionPrefix) + + schemaDescriptionHostname = `The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io +for use with Terraform Cloud.` + + schemaDescriptionOrganization = `The name of the organization containing the targeted workspace(s).` + + schemaDescriptionToken = `The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not +be set, and 'terraform login' used instead; your credentials will then be fetched from your CLI +configuration file or configured credential helper.` + + schemaDescriptionTags = `A set of tags used to select remote Terraform Cloud workspaces to be used for this single +configuration. New workspaces will automatically be tagged with these tag values. Generally, this +is the primary and recommended strategy to use. This option conflicts with "prefix" and "name".` + + schemaDescriptionName = `The name of a single Terraform Cloud workspace to be used with this configuration When configured +only the specified workspace can be used. This option conflicts with "tags" and "prefix".` + + schemaDescriptionPrefix = `DEPRECATED. A name prefix used to select remote Terraform Cloud to be used for this single configuration. New +workspaces will automatically be prefixed with this prefix. This option conflicts with "tags" and "name".` +) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 945c56378..c5115d200 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -51,6 +51,7 @@ func TestCloud_PrepareConfig(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedErr: `Invalid organization value: The "organization" attribute value must not be empty.`, @@ -60,17 +61,18 @@ func TestCloud_PrepareConfig(t *testing.T) { "organization": cty.StringVal("org"), "workspaces": cty.NullVal(cty.String), }), - expectedErr: `Invalid workspaces configuration: Either workspace "name" or "prefix" is required.`, + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags", "name", or "prefix" is required.`, }, - "workspace: empty name and empty prefix": { + "workspace: empty tags, name, and prefix": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedErr: `Invalid workspaces configuration: Either workspace "name" or "prefix" is required.`, + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags", "name", or "prefix" is required.`, }, "workspace: name and prefix present": { config: cty.ObjectVal(map[string]cty.Value{ @@ -78,9 +80,25 @@ func TestCloud_PrepareConfig(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.StringVal("app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedErr: `Invalid workspaces configuration: Only one of workspace "name" or "prefix" is allowed.`, + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags", "name", or "prefix" is allowed.`, + }, + "workspace: name and tags present": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags", "name", or "prefix" is allowed.`, }, } @@ -113,6 +131,7 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "organization \"nonexisting\" at host app.terraform.io not found", @@ -125,6 +144,7 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "Failed to request discovery document", @@ -138,10 +158,27 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "terraform login localhost", }, + "with_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + }, "with_a_name": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), @@ -150,6 +187,7 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), }, @@ -161,10 +199,11 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("my-app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), }, - "without_either_a_name_and_a_prefix": { + "without_a_name_prefix_or_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), @@ -172,9 +211,10 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - valErr: `Either workspace "name" or "prefix" is required`, + valErr: `Missing workspace mapping strategy.`, }, "with_both_a_name_and_a_prefix": { config: cty.ObjectVal(map[string]cty.Value{ @@ -184,9 +224,27 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.StringVal("my-app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - valErr: `Only one of workspace "name" or "prefix" is allowed`, + valErr: `Only one of workspace "tags", "name", or "prefix" is allowed.`, + }, + "with_both_a_name_and_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + valErr: `Only one of workspace "tags", "name", or "prefix" is allowed.`, }, "null config": { config: cty.NullVal(cty.EmptyObject), @@ -222,6 +280,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { expectedOrganziation string expectedWorkspacePrefix string expectedWorkspaceName string + expectedWorkspaceTags []string expectedForceLocal bool setEnv func() resetEnv func() @@ -234,6 +293,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -246,6 +306,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: defaultHostname, @@ -258,6 +319,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -271,12 +333,31 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", expectedOrganziation: "hashicorp", expectedWorkspacePrefix: "prod", }, + "with workspace tags set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspaceTags: []string{"billing"}, + }, "with force local set": { obj: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), @@ -284,6 +365,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -317,16 +399,51 @@ func TestCloud_setConfigurationFields(t *testing.T) { } if tc.expectedHostname != "" && b.hostname != tc.expectedHostname { - t.Fatalf("%s: expected hostname %s to match actual hostname %s", name, tc.expectedHostname, b.hostname) + t.Fatalf("%s: expected hostname %s to match configured hostname %s", name, b.hostname, tc.expectedHostname) } if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { - t.Fatalf("%s: expected organization %s to match actual organization %s", name, tc.expectedOrganziation, b.organization) + t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganziation) } if tc.expectedWorkspacePrefix != "" && b.workspaceMapping.prefix != tc.expectedWorkspacePrefix { - t.Fatalf("%s: expected workspace prefix %s to match actual workspace prefix %s", name, tc.expectedWorkspacePrefix, b.workspaceMapping.prefix) + t.Fatalf("%s: expected workspace prefix mapping (%s) to match configured workspace prefix (%s)", name, b.workspaceMapping.prefix, tc.expectedWorkspacePrefix) } if tc.expectedWorkspaceName != "" && b.workspaceMapping.name != tc.expectedWorkspaceName { - t.Fatalf("%s: expected workspace name %s to match actual workspace name %s", name, tc.expectedWorkspaceName, b.workspaceMapping.name) + t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.workspaceMapping.name, tc.expectedWorkspaceName) + } + if len(tc.expectedWorkspaceTags) > 0 { + presentSet := make(map[string]struct{}) + for _, tag := range b.workspaceMapping.tags { + presentSet[tag] = struct{}{} + } + + expectedSet := make(map[string]struct{}) + for _, tag := range tc.expectedWorkspaceTags { + expectedSet[tag] = struct{}{} + } + + var missing []string + var unexpected []string + + for _, expected := range tc.expectedWorkspaceTags { + if _, ok := presentSet[expected]; !ok { + missing = append(missing, expected) + } + } + + for _, actual := range b.workspaceMapping.tags { + if _, ok := expectedSet[actual]; !ok { + unexpected = append(missing, actual) + } + } + + if len(missing) > 0 { + t.Fatalf("%s: expected workspace tag mapping (%s) to contain the following tags: %s", name, b.workspaceMapping.tags, missing) + } + + if len(unexpected) > 0 { + t.Fatalf("%s: expected workspace tag mapping (%s) to NOT contain the following tags: %s", name, b.workspaceMapping.tags, unexpected) + } + } if tc.expectedForceLocal != false && b.forceLocal != tc.expectedForceLocal { t.Fatalf("%s: expected force local backend to be set ", name) @@ -349,6 +466,7 @@ func TestCloud_versionConstraints(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), version: "0.11.1", @@ -361,6 +479,7 @@ func TestCloud_versionConstraints(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), version: "0.0.1", @@ -374,6 +493,7 @@ func TestCloud_versionConstraints(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), version: "10.0.1", diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 273f8a157..81dfe7bb7 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -1,6 +1,8 @@ package cloud import ( + "fmt" + "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" ) @@ -9,21 +11,21 @@ var ( invalidOrganizationConfigMissingValue = tfdiags.AttributeValue( tfdiags.Error, "Invalid organization value", - `The "organization" attribute value must not be empty.`, + `The "organization" attribute value must not be empty.\n\n%s`, cty.Path{cty.GetAttrStep{Name: "organization"}}, ) invalidWorkspaceConfigMissingValues = tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", - `Either workspace "name" or "prefix" is required.`, + fmt.Sprintf("Missing workspace mapping strategy. Either workspace \"tags\", \"name\", or \"prefix\" is required.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) invalidWorkspaceConfigMisconfiguration = tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", - `Only one of workspace "name" or "prefix" is allowed.`, + fmt.Sprintf("Only one of workspace \"tags\", \"name\", or \"prefix\" is allowed.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) ) diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index cfc8d3dce..726ba0595 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -72,6 +72,7 @@ func testBackendDefault(t *testing.T) (*Cloud, func()) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) @@ -85,6 +86,7 @@ func testBackendNoDefault(t *testing.T) (*Cloud, func()) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("my-app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) @@ -98,6 +100,7 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj)