Add ability to specify Terraform Cloud Project in cloud block (#33489)

* Add ability to specify Terraform Cloud Project in cloud block

Adds project configuration to the workspaces section of the cloud block.
Also configurable via the `TF_CLOUD_PROJECT` environment variable.
When a project is configured, the following behaviors will occur:
- `terraform init` with workspaces.name configured will create the workspace in the given project
- `terraform workspace new <name>` with workspaces.tags configured will create workspaces in the given project
- `terraform workspace list` will list workspaces only from the given project

The following behaviors are NOT affected by project configuration
- `terraform workspace delete <name>` does not validate the workspace's inclusion in the given project
- When initializing a workspace that already exists in Terraform Cloud, the workspace's parent project is NOT validated against the given project

Adds tests for cloud block configuration of project
Update changelog

* Update cloud block docs

* Fix typos and changelog entry

* Add speculative project lookup early in the cloud initialize process to capture inability to find a configured project

* Add project config for alias test
pull/33621/head
Karl Kirch 3 years ago committed by GitHub
parent 3bea1171af
commit d7e07e66fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,6 +10,7 @@ ENHANCEMENTS:
* jsonplan: Added `errored` field to JSON plan output, indicating whether a plan errored. [GH-33372]
* cloud: Remote plans on Terraform Cloud/Enterprise can now be saved using the `-out` flag, referenced in the `show` command, and applied by specifying the plan file name.
BUG FIXES:
* The upstream dependency that Terraform uses for service discovery of Terraform-native services such as Terraform Cloud/Enterprise state storage was previously not concurrency-safe, but Terraform was treating it as if it was in situations like when a configuration has multiple `terraform_remote_state` blocks all using the "remote" backend. Terraform is now using a newer version of that library which updates its internal caches in a concurrency-safe way. [GH-33364]
* Transitive dependencies were lost during apply when the referenced resource expanded into zero instances [GH-33403]

@ -148,6 +148,11 @@ func (b *Cloud) ConfigSchema() *configschema.Block {
Optional: true,
Description: schemaDescriptionName,
},
"project": {
Type: cty.String,
Optional: true,
Description: schemaDescriptionProject,
},
"tags": {
Type: cty.Set(cty.String),
Optional: true,
@ -178,6 +183,9 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
}
WorkspaceMapping := WorkspaceMapping{}
// Initially set the workspace name via env var
WorkspaceMapping.Name = os.Getenv("TF_WORKSPACE")
if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
if val := workspaces.GetAttr("name"); !val.IsNull() {
WorkspaceMapping.Name = val.AsString()
@ -185,11 +193,9 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
if val := workspaces.GetAttr("tags"); !val.IsNull() {
err := gocty.FromCtyValue(val, &WorkspaceMapping.Tags)
if err != nil {
log.Panicf("An unxpected error occurred: %s", err)
log.Panicf("An unexpected error occurred: %s", err)
}
}
} else {
WorkspaceMapping.Name = os.Getenv("TF_WORKSPACE")
}
switch WorkspaceMapping.Strategy() {
@ -413,10 +419,21 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics {
b.organization = val.AsString()
}
// Initially set the project via env var
b.WorkspaceMapping.Project = os.Getenv("TF_CLOUD_PROJECT")
// Initially set the workspace name via env var
b.WorkspaceMapping.Name = os.Getenv("TF_WORKSPACE")
// Get the workspaces configuration block and retrieve the
// default workspace name.
if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
// Check if the project is present and valid in the config.
if val := workspaces.GetAttr("project"); !val.IsNull() && val.AsString() != "" {
b.WorkspaceMapping.Project = val.AsString()
}
// PrepareConfig checks that you cannot set both of these.
if val := workspaces.GetAttr("name"); !val.IsNull() {
b.WorkspaceMapping.Name = val.AsString()
@ -430,8 +447,6 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics {
b.WorkspaceMapping.Tags = tags
}
} else {
b.WorkspaceMapping.Name = os.Getenv("TF_WORKSPACE")
}
// Determine if we are forced to use the local backend.
@ -534,6 +549,22 @@ func (b *Cloud) Workspaces() ([]string, error) {
options.Tags = taglist
}
if b.WorkspaceMapping.Project != "" {
listOpts := &tfe.ProjectListOptions{
Name: b.WorkspaceMapping.Project,
}
projects, err := b.client.Projects.List(context.Background(), b.organization, listOpts)
if err != nil && err != tfe.ErrResourceNotFound {
return nil, fmt.Errorf("failed to retrieve project %s: %v", listOpts.Name, err)
}
for _, p := range projects.Items {
if p.Name == b.WorkspaceMapping.Project {
options.ProjectID = p.ID
break
}
}
}
for {
wl, err := b.client.Workspaces.List(context.Background(), b.organization, options)
if err != nil {
@ -603,17 +634,66 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) {
remoteTFVersion = workspace.TerraformVersion
}
var configuredProject *tfe.Project
// Attempt to find project if configured
if b.WorkspaceMapping.Project != "" {
listOpts := &tfe.ProjectListOptions{
Name: b.WorkspaceMapping.Project,
}
projects, err := b.client.Projects.List(context.Background(), b.organization, listOpts)
if err != nil && err != tfe.ErrResourceNotFound {
// This is a failure to make an API request, fail to initialize
return nil, fmt.Errorf("Attempted to find configured project %s but was unable to.", b.WorkspaceMapping.Project)
}
for _, p := range projects.Items {
if p.Name == b.WorkspaceMapping.Project {
configuredProject = p
break
}
}
if configuredProject == nil {
// We were able to read project, but were unable to find the configured project
// This is not fatal as we may attempt to create the project if we need to create
// the workspace
log.Printf("[TRACE] cloud: Attempted to find configured project %s but was unable to.", b.WorkspaceMapping.Project)
}
}
if err == tfe.ErrResourceNotFound {
// Create a workspace
options := tfe.WorkspaceCreateOptions{
Name: tfe.String(name),
Tags: b.WorkspaceMapping.tfeTags(),
// Create workspace if it was not found
// Workspace Create Options
workspaceCreateOptions := tfe.WorkspaceCreateOptions{
Name: tfe.String(name),
Tags: b.WorkspaceMapping.tfeTags(),
Project: configuredProject,
}
// Create project if not exists, otherwise use it
if workspaceCreateOptions.Project == nil && b.WorkspaceMapping.Project != "" {
// If we didn't find the project, try to create it
if workspaceCreateOptions.Project == nil {
createOpts := tfe.ProjectCreateOptions{
Name: b.WorkspaceMapping.Project,
}
// didn't find project, create it instead
log.Printf("[TRACE] cloud: Creating Terraform Cloud project %s/%s", b.organization, b.WorkspaceMapping.Project)
project, err := b.client.Projects.Create(context.Background(), b.organization, createOpts)
if err != nil && err != tfe.ErrResourceNotFound {
return nil, fmt.Errorf("failed to create project %s: %v", b.WorkspaceMapping.Project, err)
}
configuredProject = project
workspaceCreateOptions.Project = configuredProject
}
}
// Create a workspace
log.Printf("[TRACE] cloud: Creating Terraform Cloud workspace %s/%s", b.organization, name)
workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options)
workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, workspaceCreateOptions)
if err != nil {
return nil, fmt.Errorf("Error creating workspace %s: %v", name, err)
return nil, fmt.Errorf("error creating workspace %s: %v", name, err)
}
remoteTFVersion = workspace.TerraformVersion
@ -990,8 +1070,9 @@ func (b *Cloud) workspaceTagsRequireUpdate(workspace *tfe.Workspace, workspaceMa
}
type WorkspaceMapping struct {
Name string
Tags []string
Name string
Project string
Tags []string
}
type workspaceStrategy string
@ -1217,4 +1298,6 @@ is the primary and recommended strategy to use. This option conflicts with "nam
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".`
schemaDescriptionProject = `The name of a project that resulting workspace(s) will be created in.`
)

@ -85,8 +85,9 @@ func TestCloud_PrepareConfig(t *testing.T) {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedErr: `Invalid or missing required argument: "organization" must be set in the cloud configuration or as an environment variable: TF_CLOUD_ORGANIZATION.`,
@ -102,8 +103,9 @@ func TestCloud_PrepareConfig(t *testing.T) {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("org"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.NullVal(cty.String),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.NullVal(cty.String),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`,
@ -112,8 +114,9 @@ func TestCloud_PrepareConfig(t *testing.T) {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("org"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`,
@ -128,6 +131,7 @@ func TestCloud_PrepareConfig(t *testing.T) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
}),
expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`,
@ -159,8 +163,9 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -171,8 +176,9 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{},
@ -187,7 +193,7 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
"TF_WORKSPACE": "my-workspace",
},
},
"organization and workspace env var": {
"organization and workspace and project env var": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.NullVal(cty.String),
@ -195,6 +201,43 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
vars: map[string]string{
"TF_CLOUD_ORGANIZATION": "hashicorp",
"TF_WORKSPACE": "my-workspace",
"TF_CLOUD_PROJECT": "example-project",
},
},
"with no project": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("organization"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
},
"with null project": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("organization"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
"TF_CLOUD_PROJECT": "example-project",
},
},
"with project env var ovewrite config value": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("organization"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.StringVal("project-name"),
}),
}),
vars: map[string]string{
"TF_CLOUD_PROJECT": "example-project",
},
},
}
@ -224,7 +267,7 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
}
}
func TestCloud_configWithEnvVars(t *testing.T) {
func WithEnvVars(t *testing.T) {
cases := map[string]struct {
setup func(b *Cloud)
config cty.Value
@ -232,6 +275,7 @@ func TestCloud_configWithEnvVars(t *testing.T) {
expectedOrganization string
expectedHostname string
expectedWorkspaceName string
expectedProjectName string
expectedErr string
}{
"with no organization specified": {
@ -240,8 +284,9 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"token": cty.NullVal(cty.String),
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -255,8 +300,9 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -270,8 +316,9 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -285,8 +332,9 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -300,8 +348,9 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"workspaces": cty.NullVal(cty.Object(map[string]cty.Type{
"name": cty.String,
"tags": cty.Set(cty.String),
"name": cty.String,
"tags": cty.Set(cty.String),
"project": cty.String,
})),
}),
vars: map[string]string{
@ -315,8 +364,9 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("mordor"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("mt-doom"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("mt-doom"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -343,6 +393,7 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"tags": cty.SetVal([]cty.Value{
cty.StringVal("cloud"),
}),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -374,6 +425,7 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"tags": cty.SetVal([]cty.Value{
cty.StringVal("hobbity"),
}),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
@ -381,6 +433,83 @@ func TestCloud_configWithEnvVars(t *testing.T) {
},
expectedWorkspaceName: "", // No error is raised, but workspace is not set
},
"project specified": {
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("mordor"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("mt-doom"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.StringVal("my-project"),
}),
}),
expectedWorkspaceName: "mt-doom",
expectedProjectName: "my-project",
},
"project env var specified": {
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("mordor"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("mt-doom"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
"TF_CLOUD_PROJECT": "other-project",
},
expectedWorkspaceName: "mt-doom",
expectedProjectName: "other-project",
},
"project and env var specified": {
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("mordor"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("mt-doom"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.StringVal("my-project"),
}),
}),
vars: map[string]string{
"TF_CLOUD_PROJECT": "other-project",
},
expectedWorkspaceName: "mt-doom",
expectedProjectName: "my-project",
},
"workspace exists but in different project": {
setup: func(b *Cloud) {
b.client.Organizations.Create(context.Background(), tfe.OrganizationCreateOptions{
Name: tfe.String("mordor"),
})
project, _ := b.client.Projects.Create(context.Background(), "mordor", tfe.ProjectCreateOptions{
Name: "another-project",
})
b.client.Workspaces.Create(context.Background(), "mordor", tfe.WorkspaceCreateOptions{
Name: tfe.String("shire"),
Project: project,
})
},
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"token": cty.NullVal(cty.String),
"organization": cty.StringVal("mordor"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.NullVal(cty.String),
"tags": cty.SetVal([]cty.Value{
cty.StringVal("hobbity"),
}),
"project": cty.StringVal("my-project"),
}),
}),
expectedProjectName: "another-project", // No error is raised, workspace is still in the original project
},
"with everything set as env vars": {
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
@ -392,10 +521,12 @@ func TestCloud_configWithEnvVars(t *testing.T) {
"TF_CLOUD_ORGANIZATION": "mordor",
"TF_WORKSPACE": "mt-doom",
"TF_CLOUD_HOSTNAME": "mycool.tfe-host.io",
"TF_CLOUD_PROJECT": "my-project",
},
expectedOrganization: "mordor",
expectedWorkspaceName: "mt-doom",
expectedHostname: "mycool.tfe-host.io",
expectedProjectName: "my-project",
},
}
@ -440,6 +571,10 @@ func TestCloud_configWithEnvVars(t *testing.T) {
if tc.expectedWorkspaceName != "" && tc.expectedWorkspaceName != b.WorkspaceMapping.Name {
t.Fatalf("%s: workspace name not valid: %s, expected: %s", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName)
}
if tc.expectedProjectName != "" && tc.expectedProjectName != b.WorkspaceMapping.Project {
t.Fatalf("%s: project name not valid: %s, expected: %s", name, b.WorkspaceMapping.Project, tc.expectedProjectName)
}
})
}
}
@ -456,8 +591,9 @@ func TestCloud_config(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
confErr: "Host nontfe.local does not provide a tfe service",
@ -469,8 +605,9 @@ func TestCloud_config(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
confErr: "terraform login localhost",
@ -487,6 +624,7 @@ func TestCloud_config(t *testing.T) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
}),
},
@ -496,8 +634,9 @@ func TestCloud_config(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
},
@ -507,8 +646,9 @@ func TestCloud_config(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.NullVal(cty.String),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.NullVal(cty.String),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
valErr: `Missing workspace mapping strategy.`,
@ -525,6 +665,7 @@ func TestCloud_config(t *testing.T) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
}),
valErr: `Only one of workspace "tags" or "name" is allowed.`,
@ -568,6 +709,7 @@ func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
})
@ -604,6 +746,7 @@ func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
})
@ -647,6 +790,7 @@ func TestCloud_setUnavailableTerraformVersion(t *testing.T) {
cty.StringVal("sometag"),
},
),
"project": cty.NullVal(cty.String),
}),
})
@ -690,6 +834,7 @@ func TestCloud_setConfigurationFields(t *testing.T) {
expectedHostname string
expectedOrganziation string
expectedWorkspaceName string
expectedProjectName string
expectedWorkspaceTags []string
expectedForceLocal bool
setEnv func()
@ -701,8 +846,9 @@ func TestCloud_setConfigurationFields(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"hostname": cty.StringVal("hashicorp.com"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedHostname: "hashicorp.com",
@ -713,8 +859,9 @@ func TestCloud_setConfigurationFields(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"hostname": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedHostname: defaultHostname,
@ -725,8 +872,9 @@ func TestCloud_setConfigurationFields(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"hostname": cty.StringVal("hashicorp.com"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedHostname: "hashicorp.com",
@ -744,19 +892,36 @@ func TestCloud_setConfigurationFields(t *testing.T) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
}),
expectedHostname: "hashicorp.com",
expectedOrganziation: "hashicorp",
expectedWorkspaceTags: []string{"billing"},
},
"with project name 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.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.StringVal("my-project"),
}),
}),
expectedHostname: "hashicorp.com",
expectedOrganziation: "hashicorp",
expectedWorkspaceName: "prod",
expectedProjectName: "my-project",
},
"with force local 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),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.NullVal(cty.String),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedHostname: "hashicorp.com",
@ -835,6 +1000,9 @@ func TestCloud_setConfigurationFields(t *testing.T) {
if tc.expectedForceLocal != false && b.forceLocal != tc.expectedForceLocal {
t.Fatalf("%s: expected force local backend to be set ", name)
}
if tc.expectedProjectName != "" && b.WorkspaceMapping.Project != tc.expectedProjectName {
t.Fatalf("%s: expected project name mapping (%s) to match configured project name (%s)", name, b.WorkspaceMapping.Project, tc.expectedProjectName)
}
}
}
@ -1230,8 +1398,9 @@ func TestCloud_ServiceDiscoveryAliases(t *testing.T) {
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}))
if diag.HasErrors() {

@ -93,8 +93,9 @@ func testBackendAndMocksWithName(t *testing.T) (*Cloud, *MockClient, func()) {
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
return testBackend(t, obj, defaultTFCPing)
@ -112,6 +113,7 @@ func testBackendWithTags(t *testing.T) (*Cloud, func()) {
cty.StringVal("billing"),
},
),
"project": cty.NullVal(cty.String),
}),
})
b, _, c := testBackend(t, obj, nil)
@ -124,8 +126,9 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
"organization": cty.StringVal("no-operations"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
b, _, c := testBackend(t, obj, nil)
@ -138,8 +141,9 @@ func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.Respons
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"name": cty.StringVal(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
b, _, c := testBackend(t, obj, handlers)

@ -34,6 +34,7 @@ type MockClient struct {
TaskStages *MockTaskStages
RedactedPlans *MockRedactedPlans
PolicyChecks *MockPolicyChecks
Projects *MockProjects
Runs *MockRuns
RunEvents *MockRunEvents
StateVersions *MockStateVersions
@ -52,6 +53,7 @@ func NewMockClient() *MockClient {
c.TaskStages = newMockTaskStages(c)
c.PolicySetOutcomes = newMockPolicySetOutcomes(c)
c.PolicyChecks = newMockPolicyChecks(c)
c.Projects = newMockProjects(c)
c.Runs = newMockRuns(c)
c.RunEvents = newMockRunEvents(c)
c.StateVersions = newMockStateVersions(c)
@ -939,6 +941,102 @@ func (m *MockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.R
return bytes.NewBuffer(logs), nil
}
type MockProjects struct {
client *MockClient
projects map[string]*tfe.Project
}
func newMockProjects(client *MockClient) *MockProjects {
return &MockProjects{
client: client,
projects: make(map[string]*tfe.Project),
}
}
func (m *MockProjects) Create(ctx context.Context, organization string, options tfe.ProjectCreateOptions) (*tfe.Project, error) {
id := GenerateID("prj-")
p := &tfe.Project{
ID: id,
Name: options.Name,
}
m.projects[p.ID] = p
return p, nil
}
func (m *MockProjects) List(ctx context.Context, organization string, options *tfe.ProjectListOptions) (*tfe.ProjectList, error) {
pl := &tfe.ProjectList{}
for _, project := range m.projects {
pc, err := copystructure.Copy(project)
if err != nil {
panic(err)
}
pl.Items = append(pl.Items, pc.(*tfe.Project))
}
pl.Pagination = &tfe.Pagination{
CurrentPage: 1,
NextPage: 1,
PreviousPage: 1,
TotalPages: 1,
TotalCount: len(pl.Items),
}
return pl, nil
}
func (m *MockProjects) Read(ctx context.Context, projectID string) (*tfe.Project, error) {
p, ok := m.projects[projectID]
if !ok {
return nil, tfe.ErrResourceNotFound
}
// we must return a copy for the client
pc, err := copystructure.Copy(p)
if err != nil {
panic(err)
}
return pc.(*tfe.Project), nil
}
func (m *MockProjects) Update(ctx context.Context, projectID string, options tfe.ProjectUpdateOptions) (*tfe.Project, error) {
p, ok := m.projects[projectID]
if !ok {
return nil, tfe.ErrResourceNotFound
}
p.Name = *options.Name
// we must return a copy for the client
pc, err := copystructure.Copy(p)
if err != nil {
panic(err)
}
return pc.(*tfe.Project), nil
}
func (m *MockProjects) Delete(ctx context.Context, projectID string) error {
var p *tfe.Project = nil
for _, p := range m.projects {
if p.ID == projectID {
break
}
}
if p == nil {
return tfe.ErrResourceNotFound
}
delete(m.projects, p.Name)
return nil
}
type MockRuns struct {
sync.Mutex
@ -1556,6 +1654,9 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option
Name: organization,
},
}
if options.Project != nil {
w.Project = options.Project
}
if options.AutoApply != nil {
w.AutoApply = *options.AutoApply
}

@ -35,6 +35,7 @@ terraform {
hostname = "app.terraform.io" # Optional; defaults to app.terraform.io
workspaces {
project = "networking-development"
tags = ["networking", "source:cli"]
}
}
@ -75,6 +76,10 @@ The `cloud` block supports the following configuration arguments:
directory, and cannot manage workspaces from the CLI (e.g. `terraform workspace select` or
`terraform workspace new`). This option conflicts with `tags`.
- `project` - (Optional) The name of a Terraform Cloud project. Workspaces that need created will
will be created within this project. `terraform workspace list` will be filtered by workspaces
in the supplied project.
- `hostname` - (Optional) The hostname of a Terraform Enterprise installation, if using Terraform
Enterprise. Defaults to Terraform Cloud (app.terraform.io).
@ -98,6 +103,8 @@ Use the following environment variables to configure the `cloud` block:
- `TF_CLOUD_HOSTNAME` - The hostname of a Terraform Enterprise installation. Terraform reads this when `hostname` is omitted from the `cloud` block. If both are specified, the configuration takes precedence.
- `TF_CLOUD_PROJECT` - The name of a Terraform Cloud project. Terraform reads this when `workspaces.project` is omitted from the `cloud` block. If both are specified, the cloud block configuration takes precedence.
- `TF_WORKSPACE` - The name of a single Terraform Cloud workspace. Terraform reads this when `workspaces` is omitted from the `cloud` block. Terraform Cloud will not create a new workspace from this variable; the workspace must exist in the specified organization. You can set `TF_WORKSPACE` if the `cloud` block uses tags. However, the value of `TF_WORKSPACE` must be included in the set of tags. This variable also selects the workspace in your local environment. Refer to [TF_WORKSPACE](/terraform/cli/config/environment-variables#tf_workspace) for details.
## Excluding Files from Upload with .terraformignore

Loading…
Cancel
Save