From d7e07e66fc0bda7db526e867b230dea8018b496a Mon Sep 17 00:00:00 2001 From: Karl Kirch Date: Tue, 1 Aug 2023 15:43:07 -0500 Subject: [PATCH] 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 ` 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 ` 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 --- CHANGELOG.md | 1 + internal/cloud/backend.go | 109 ++++++++++-- internal/cloud/backend_test.go | 253 +++++++++++++++++++++++----- internal/cloud/testing.go | 16 +- internal/cloud/tfe_client_mock.go | 101 +++++++++++ website/docs/cli/cloud/settings.mdx | 7 + 6 files changed, 426 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0efc4151a9..d6d43965c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 4fa2fc1ef0..390ece61d5 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -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.` ) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 89847350b2..cc3c9d32ee 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -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() { diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 6752e7e97d..574cad69c0 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -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) diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index ea7191bc8c..0d5e1a61b8 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -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 } diff --git a/website/docs/cli/cloud/settings.mdx b/website/docs/cli/cloud/settings.mdx index 98b16ec040..0c10dbb375 100644 --- a/website/docs/cli/cloud/settings.mdx +++ b/website/docs/cli/cloud/settings.mdx @@ -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