diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f178f4f5..db727c72a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ BUG FIXES: * Fixed regression where `tofu` was not ignoring GPG signing key expiration by default ([822](https://github.com/opentofu/opentofu/issues/822)) - Added experimental env variable to enforce checking of GPG signing key expiration `OPENTOFU_ENFORCE_GPG_EXPIRATION=true` - This is planned to default to `true` in a future release. +* cloud: fixed a bug related to `workspaces` configuration not correctly recognizing TF_WORKSPACE environment variable ([#814](https://github.com/opentofu/opentofu/issues/814)) S3 BACKEND: diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 0caffa33bc..745c38cfe6 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -182,20 +182,11 @@ 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") + // Consider preserving the state in the receiver because it's instantiated twice, see b.setConfigurationFields + WorkspaceMapping := newWorkspacesMappingFromFields(obj) - if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { - if val := workspaces.GetAttr("name"); !val.IsNull() { - WorkspaceMapping.Name = val.AsString() - } - if val := workspaces.GetAttr("tags"); !val.IsNull() { - err := gocty.FromCtyValue(val, &WorkspaceMapping.Tags) - if err != nil { - log.Panicf("An unexpected error occurred: %s", err) - } - } + if diag := reconcileWorkspaceMappingEnvVars(&WorkspaceMapping); diag != nil { + diags = diags.Append(diag) } switch WorkspaceMapping.Strategy() { @@ -210,6 +201,35 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { return obj, diags } +func newWorkspacesMappingFromFields(obj cty.Value) WorkspaceMapping { + mapping := WorkspaceMapping{} + + config := obj.GetAttr("workspaces") + if config.IsNull() { + return mapping + } + + workspaceName := config.GetAttr("name") + if !workspaceName.IsNull() { + mapping.Name = workspaceName.AsString() + } + + workspaceTags := config.GetAttr("tags") + if !workspaceTags.IsNull() { + err := gocty.FromCtyValue(workspaceTags, &mapping.Tags) + if err != nil { + log.Panicf("An unexpected error occurred: %s", err) + } + } + + projectName := config.GetAttr("project") + if !projectName.IsNull() && projectName.AsString() != "" { + mapping.Project = projectName.AsString() + } + + return mapping +} + func (b *Cloud) ServiceDiscoveryAliases() ([]backend.HostAlias, error) { aliasHostname, err := svchost.ForComparison(genericHostname) if err != nil { @@ -424,34 +444,12 @@ 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 workspaces from the configuration + b.WorkspaceMapping = newWorkspacesMappingFromFields(obj) - // 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() - } - if val := workspaces.GetAttr("tags"); !val.IsNull() { - var tags []string - err := gocty.FromCtyValue(val, &tags) - if err != nil { - log.Panicf("An unexpected error occurred: %s", err) - } - - b.WorkspaceMapping.Tags = tags - } + // Overwrite workspaces config from env variable + if diag := reconcileWorkspaceMappingEnvVars(&b.WorkspaceMapping); diag != nil { + return diags.Append(diag) } // Determine if we are forced to use the local backend. @@ -460,6 +458,32 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { return diags } +func reconcileWorkspaceMappingEnvVars(w *WorkspaceMapping) tfdiags.Diagnostic { + // See: https://github.com/opentofu/opentofu/issues/814 + if v := os.Getenv("TF_WORKSPACE"); v != "" && w.Name == "" { + if len(w.Tags) > 0 && !workspaceInTags(w.Tags, v) { + return invalidWorkspaceConfigMisconfigurationEnvVar + } + w.Name = v + w.Tags = nil + } + + if v := os.Getenv("TF_CLOUD_PROJECT"); v != "" && w.Project == "" { + w.Project = v + } + + return nil +} + +func workspaceInTags(tags []string, workspace string) bool { + for _, tag := range tags { + if tag == workspace { + return true + } + } + return false +} + // discover the TFC/E API service URL and version constraints. func (b *Cloud) discover() (*url.URL, error) { hostname, err := svchost.ForComparison(b.hostname) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 7154a7cdf7..8d3458a49c 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -172,17 +172,19 @@ func TestCloud_PrepareConfig(t *testing.T) { } for name, tc := range cases { - s := testServer(t) - b := New(testDisco(s)) + t.Run(name, func(t *testing.T) { + s := testServer(t) + b := New(testDisco(s)) - // Validate - _, valDiags := b.PrepareConfig(tc.config) - if valDiags.Err() != nil && tc.expectedErr != "" { - actualErr := valDiags.Err().Error() - if !strings.Contains(actualErr, tc.expectedErr) { - t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + // Validate + _, valDiags := b.PrepareConfig(tc.config) + if valDiags.Err() != nil && tc.expectedErr != "" { + actualErr := valDiags.Err().Error() + if !strings.Contains(actualErr, tc.expectedErr) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } } - } + }) } } @@ -260,7 +262,7 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) { "TF_CLOUD_PROJECT": "example-project", }, }, - "with project env var ovewrite config value": { + "with project env var overwrite config value": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("organization"), "workspaces": cty.ObjectVal(map[string]cty.Value{ @@ -273,6 +275,51 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) { "TF_CLOUD_PROJECT": "example-project", }, }, + "with workspace defined by tags overwritten by TF_WORKSPACE": { + // see https://github.com/opentofu/opentofu/issues/814 for context + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("foo"), + "organization": cty.StringVal("bar"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "project": cty.NullVal(cty.String), + "tags": cty.SetVal([]cty.Value{cty.StringVal("baz"), cty.StringVal("qux")}), + }), + }), + vars: map[string]string{ + "TF_WORKSPACE": "qux", + }, + }, + "with TF_WORKSPACE value outside of the tags set": { + // see https://github.com/opentofu/opentofu/issues/814 for context + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("foo"), + "organization": cty.StringVal("bar"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "project": cty.NullVal(cty.String), + "tags": cty.SetVal([]cty.Value{cty.StringVal("baz"), cty.StringVal("qux")}), + }), + }), + vars: map[string]string{ + "TF_WORKSPACE": "quxx", + }, + expectedErr: `Invalid workspaces configuration: The workspace defined using the environment variable "TF_WORKSPACE" does not belong to "tags".`, + }, + "with workspace block w/o attributes, TF_WORKSPACE defined": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("foo"), + "organization": cty.StringVal("bar"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.NullVal(cty.String), + }), + }), + vars: map[string]string{ + "TF_WORKSPACE": "qux", + }, + }, } for name, tc := range cases { @@ -290,323 +337,13 @@ func TestCloud_PrepareConfigWithEnvVars(t *testing.T) { }) _, valDiags := b.PrepareConfig(tc.config) - if valDiags.Err() != nil && tc.expectedErr != "" { - actualErr := valDiags.Err().Error() - if !strings.Contains(actualErr, tc.expectedErr) { - t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) - } - } - }) - } -} - -func WithEnvVars(t *testing.T) { - cases := map[string]struct { - setup func(b *Cloud) - config cty.Value - vars map[string]string - expectedOrganization string - expectedHostname string - expectedWorkspaceName string - expectedProjectName string - expectedErr string - }{ - "with no organization specified": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "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)), - "project": cty.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_CLOUD_ORGANIZATION": "hashicorp", - }, - expectedOrganization: "hashicorp", - }, - "with both organization and env var specified": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "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)), - "project": cty.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_CLOUD_ORGANIZATION": "we-should-not-see-this", - }, - expectedOrganization: "hashicorp", - }, - "with no hostname specified": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "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)), - "project": cty.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_CLOUD_HOSTNAME": "private.hashicorp.engineering", - }, - expectedHostname: "private.hashicorp.engineering", - }, - "with hostname and env var specified": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.StringVal("private.hashicorp.engineering"), - "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)), - "project": cty.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_CLOUD_HOSTNAME": "mycool.tfe-host.io", - }, - expectedHostname: "private.hashicorp.engineering", - }, - "an invalid workspace env var": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "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), - "project": cty.String, - })), - }), - vars: map[string]string{ - "TF_WORKSPACE": "i-dont-exist-in-org", - }, - expectedErr: `Invalid workspace selection: OpenTofu failed to find workspace "i-dont-exist-in-org" in organization hashicorp`, - }, - "workspaces 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.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_WORKSPACE": "shire", - }, - expectedWorkspaceName: "mt-doom", - }, - "env var workspace does not have specified tag": { - setup: func(b *Cloud) { - b.client.Organizations.Create(context.Background(), tfe.OrganizationCreateOptions{ - Name: tfe.String("mordor"), - }) - - b.client.Workspaces.Create(context.Background(), "mordor", tfe.WorkspaceCreateOptions{ - Name: tfe.String("shire"), - }) - }, - 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("cloud"), - }), - "project": cty.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_WORKSPACE": "shire", - }, - expectedErr: "OpenTofu failed to find workspace \"shire\" with the tags specified in your configuration:\n[cloud]", - }, - "env var workspace has specified tag": { - setup: func(b *Cloud) { - b.client.Organizations.Create(context.Background(), tfe.OrganizationCreateOptions{ - Name: tfe.String("mordor"), - }) - - b.client.Workspaces.Create(context.Background(), "mordor", tfe.WorkspaceCreateOptions{ - Name: tfe.String("shire"), - Tags: []*tfe.Tag{ - { - Name: "hobbity", - }, - }, - }) - }, - 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.NullVal(cty.String), - }), - }), - vars: map[string]string{ - "TF_WORKSPACE": "shire", - }, - 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), - "token": cty.NullVal(cty.String), - "organization": cty.NullVal(cty.String), - "workspaces": cty.NullVal(cty.String), - }), - vars: map[string]string{ - "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", - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - b, cleanup := testUnconfiguredBackend(t) - t.Cleanup(cleanup) - - for k, v := range tc.vars { - os.Setenv(k, v) - } - - t.Cleanup(func() { - for k := range tc.vars { - os.Unsetenv(k) - } - }) - - _, valDiags := b.PrepareConfig(tc.config) - if valDiags.Err() != nil { + if (valDiags.Err() == nil) != (tc.expectedErr == "") { t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) } - - if tc.setup != nil { - tc.setup(b) - } - - diags := b.Configure(tc.config) - if (diags.Err() != nil || tc.expectedErr != "") && - (diags.Err() == nil || !strings.Contains(diags.Err().Error(), tc.expectedErr)) { - t.Fatalf("%s: unexpected configure result: %v", name, diags.Err()) - } - - if tc.expectedOrganization != "" && tc.expectedOrganization != b.organization { - t.Fatalf("%s: organization not valid: %s, expected: %s", name, b.organization, tc.expectedOrganization) - } - - if tc.expectedHostname != "" && tc.expectedHostname != b.hostname { - t.Fatalf("%s: hostname not valid: %s, expected: %s", name, b.hostname, tc.expectedHostname) - } - - 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) + if valDiags.Err() != nil { + if !strings.Contains(valDiags.Err().Error(), tc.expectedErr) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } } }) } @@ -859,170 +596,312 @@ func TestCloud_setUnavailableTerraformVersion(t *testing.T) { } } -func TestCloud_setConfigurationFields(t *testing.T) { - originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND") - +func TestCloud_setConfigurationFieldsHappyPath(t *testing.T) { cases := map[string]struct { obj cty.Value + envVars map[string]string expectedHostname string - expectedOrganziation string + expectedOrganization string expectedWorkspaceName string expectedProjectName string - expectedWorkspaceTags []string + expectedWorkspaceTags map[string]struct{} expectedForceLocal bool - setEnv func() - resetEnv func() - expectedErr string }{ - "with hostname set": { + "with hostname, organization and tags set": { obj: cty.ObjectVal(map[string]cty.Value{ - "organization": cty.StringVal("hashicorp"), - "hostname": cty.StringVal("hashicorp.com"), + "organization": cty.StringVal("opentofu"), + "hostname": cty.StringVal("opentofu.org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.NullVal(cty.String), + "tags": cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), "project": cty.NullVal(cty.String), }), }), - expectedHostname: "hashicorp.com", - expectedOrganziation: "hashicorp", + expectedHostname: "opentofu.org", + expectedOrganization: "opentofu", + expectedWorkspaceTags: map[string]struct{}{"foo": {}, "bar": {}}, }, - "with workspace name set": { + "with hostname and workspace name set": { obj: cty.ObjectVal(map[string]cty.Value{ - "organization": cty.StringVal("hashicorp"), - "hostname": cty.StringVal("hashicorp.com"), + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.NullVal(cty.String), }), }), - expectedHostname: "hashicorp.com", - expectedOrganziation: "hashicorp", + expectedHostname: "opentofu.org", expectedWorkspaceName: "prod", }, - "with workspace tags set": { + "with hostname and project name set": { obj: cty.ObjectVal(map[string]cty.Value{ - "organization": cty.StringVal("hashicorp"), - "hostname": cty.StringVal("hashicorp.com"), + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "tags": cty.SetVal( - []cty.Value{ - 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"), + "name": cty.NullVal(cty.String), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.StringVal("my-project"), }), }), - expectedHostname: "hashicorp.com", - expectedOrganziation: "hashicorp", - expectedWorkspaceName: "prod", - expectedProjectName: "my-project", + expectedHostname: "opentofu.org", + expectedProjectName: "my-project", }, - "with force local set": { + "with hostname and force local set (env var)": { obj: cty.ObjectVal(map[string]cty.Value{ - "organization": cty.StringVal("hashicorp"), - "hostname": cty.StringVal("hashicorp.com"), + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.NullVal(cty.String), }), }), - expectedHostname: "hashicorp.com", - expectedOrganziation: "hashicorp", - setEnv: func() { - os.Setenv("TF_FORCE_LOCAL_BACKEND", "1") - }, - resetEnv: func() { - os.Setenv("TF_FORCE_LOCAL_BACKEND", originalForceBackendEnv) + expectedHostname: "opentofu.org", + envVars: map[string]string{ + "TF_FORCE_LOCAL_BACKEND": "1", }, expectedForceLocal: true, }, + "with hostname and workspace tags set, and tags overwritten by TF_WORKSPACE": { + // see: https://github.com/opentofu/opentofu/issues/814 + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_WORKSPACE": "foo", + }, + expectedHostname: "opentofu.org", + expectedWorkspaceName: "foo", + expectedWorkspaceTags: nil, + }, + "with hostname and workspace name set, and TF_WORKSPACE specified": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("old"), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_WORKSPACE": "new", + }, + expectedHostname: "opentofu.org", + expectedWorkspaceName: "old", + expectedWorkspaceTags: nil, + }, + "with hostname and project set, and project overwritten by TF_CLOUD_PROJECT": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.StringVal("old"), + }), + }), + envVars: map[string]string{ + "TF_CLOUD_PROJECT": "new", + }, + expectedHostname: "opentofu.org", + expectedProjectName: "old", + }, + "with hostname set, and project specified by TF_CLOUD_PROJECT": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_CLOUD_PROJECT": "new", + }, + expectedHostname: "opentofu.org", + expectedProjectName: "new", + }, + "with hostname set, and organization specified by TF_CLOUD_ORGANIZATION": { + obj: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("opentofu.org"), + "token": cty.NullVal(cty.String), + "organization": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_CLOUD_ORGANIZATION": "my-org", + }, + expectedHostname: "opentofu.org", + expectedOrganization: "my-org", + }, + "with hostname set, and TF_CLOUD_HOSTNAME defined": { + obj: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("opentofu.org"), + "token": cty.NullVal(cty.String), + "organization": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_CLOUD_HOSTNAME": "new", + }, + expectedHostname: "opentofu.org", + }, + "with hostname specified by TF_CLOUD_HOSTNAME": { + obj: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "token": cty.NullVal(cty.String), + "organization": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_CLOUD_HOSTNAME": "new", + }, + expectedHostname: "new", + }, + "with nothing set, all configured using env vars": { + obj: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.NullVal(cty.String), + "workspaces": cty.NullVal(cty.String), + }), + envVars: map[string]string{ + "TF_CLOUD_HOSTNAME": "opentofu.org", + "TF_CLOUD_ORGANIZATION": "opentofu", + "TF_WORKSPACE": "foo", + "TF_CLOUD_PROJECT": "bar", + }, + expectedHostname: "opentofu.org", + expectedOrganization: "opentofu", + expectedWorkspaceName: "foo", + expectedProjectName: "bar", + }, } for name, tc := range cases { - b := &Cloud{} + t.Run(name, func(t *testing.T) { + for k, v := range tc.envVars { + t.Setenv(k, v) + } - // if `setEnv` is set, then we expect `resetEnv` to also be set - if tc.setEnv != nil { - tc.setEnv() - defer tc.resetEnv() - } + b := &Cloud{} + errDiags := b.setConfigurationFields(tc.obj) - errDiags := b.setConfigurationFields(tc.obj) - if errDiags.HasErrors() || tc.expectedErr != "" { - actualErr := errDiags.Err().Error() - if !strings.Contains(actualErr, tc.expectedErr) { + if errDiags.HasErrors() { t.Fatalf("%s: unexpected validation result: %v", name, errDiags.Err()) } - } - - if tc.expectedHostname != "" && b.hostname != tc.expectedHostname { - 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 configured organization (%s)", name, b.organization, tc.expectedOrganziation) - } - if tc.expectedWorkspaceName != "" && b.WorkspaceMapping.Name != tc.expectedWorkspaceName { - 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{}{} + if b.hostname != tc.expectedHostname { + t.Fatalf("%s: expected hostname %s to match configured hostname %s", name, b.hostname, tc.expectedHostname) + } + if b.organization != tc.expectedOrganization { + t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganization) + } + if b.WorkspaceMapping.Name != tc.expectedWorkspaceName { + t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName) + } + if b.forceLocal != tc.expectedForceLocal { + t.Fatalf("%s: expected force local backend to be set to %v", name, tc.expectedForceLocal) + } + if 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) } - expectedSet := make(map[string]struct{}) - for _, tag := range tc.expectedWorkspaceTags { - expectedSet[tag] = struct{}{} + // read map of configured tags + gotTags := map[string]struct{}{} + for _, v := range b.WorkspaceMapping.Tags { + gotTags[v] = struct{}{} } - var missing []string - var unexpected []string + if len(gotTags) != len(tc.expectedWorkspaceTags) { + t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags) + } - for _, expected := range tc.expectedWorkspaceTags { - if _, ok := presentSet[expected]; !ok { - missing = append(missing, expected) + for k := range tc.expectedWorkspaceTags { + if _, ok := gotTags[k]; !ok { + t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags) } } + }) + } +} - for _, actual := range b.WorkspaceMapping.Tags { - if _, ok := expectedSet[actual]; !ok { - unexpected = append(unexpected, actual) - } +func TestCloud_setConfigurationFieldsUnhappyPath(t *testing.T) { + cases := map[string]struct { + obj cty.Value + envVars map[string]string + wantSummary string + wantDetail string + }{ + "cloud block is not configured": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "hostname": cty.NullVal(cty.String), + "workspaces": cty.NullVal(cty.String), + }), + wantSummary: "Hostname is required for the cloud backend", + wantDetail: `OpenTofu does not provide a default "hostname" attribute, so it must be set to the hostname of the cloud backend.`, + }, + "with hostname and workspace tags set, and tags overwritten by TF_WORKSPACE": { + // see: https://github.com/opentofu/opentofu/issues/814 + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "hostname": cty.StringVal("opentofu.org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), + "project": cty.NullVal(cty.String), + }), + }), + envVars: map[string]string{ + "TF_WORKSPACE": "qux", + }, + wantSummary: invalidWorkspaceConfigMisconfigurationEnvVar.Description().Summary, + wantDetail: invalidWorkspaceConfigMisconfigurationEnvVar.Description().Detail, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + for k, v := range tc.envVars { + t.Setenv(k, v) } - if len(missing) > 0 { - t.Fatalf("%s: expected workspace tag mapping (%s) to contain the following tags: %s", name, b.WorkspaceMapping.Tags, missing) + b := &Cloud{} + errDiags := b.setConfigurationFields(tc.obj) + if (tc.wantDetail != "" || tc.wantSummary != "") != errDiags.HasErrors() { + t.Fatalf("%s error expected", name) } - if len(unexpected) > 0 { - t.Fatalf("%s: expected workspace tag mapping (%s) to NOT contain the following tags: %s", name, b.WorkspaceMapping.Tags, unexpected) + gotSummary := errDiags[0].Description().Summary + if gotSummary != tc.wantSummary { + t.Fatalf("%s diagnostic summary mismatch, want: %s, got: %s", name, tc.wantSummary, gotSummary) } - } - 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) - } + gotDetail := errDiags[0].Description().Detail + if gotDetail != tc.wantDetail { + t.Fatalf("%s diagnostic details mismatch, want: %s, got: %s", name, tc.wantDetail, gotDetail) + } + }) } } diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 0f181e7b31..bc06cab712 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -38,6 +38,13 @@ var ( fmt.Sprintf("Only one of workspace \"tags\" or \"name\" is allowed.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) + + invalidWorkspaceConfigMisconfigurationEnvVar = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + fmt.Sprintf("The workspace defined using the environment variable \"TF_WORKSPACE\" does not belong to \"tags\".\n\n%s", workspaceConfigurationHelp), + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + ) ) const ignoreRemoteVersionHelp = "If you're sure you want to upgrade the state, you can force OpenTofu to continue using the -ignore-remote-version flag. This may result in an unusable workspace." diff --git a/website/docs/cli/cloud/settings.mdx b/website/docs/cli/cloud/settings.mdx index 58f5e207c7..043c72fec8 100644 --- a/website/docs/cli/cloud/settings.mdx +++ b/website/docs/cli/cloud/settings.mdx @@ -94,7 +94,7 @@ Remote execution with non-interactive workflows requires auto-approved deploymen Use the following environment variables to configure the `cloud` block: -- `TF_CLOUD_ORGANIZATION` - The name of the organization. OpenTofu reads this variable when `organization` omitted from the `cloud` block`. If both are specified, the configuration takes precedence. +- `TF_CLOUD_ORGANIZATION` - The name of the organization. OpenTofu reads this variable when `organization` omitted from the `cloud` block. If both are specified, the configuration takes precedence. - `TF_CLOUD_HOSTNAME` - The hostname of the cloud backend. OpenTofu reads this when `hostname` is omitted from the `cloud` block. If both are specified, the configuration takes precedence.