Map CLI workspaces by TFC tags

This commit is contained in:
Chris Arcand 2021-09-13 14:18:32 -05:00
parent 7a243379fb
commit 6dcd0db265
4 changed files with 209 additions and 38 deletions

View File

@ -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".`
)

View File

@ -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",

View File

@ -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"}},
)
)

View File

@ -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)