Fix workspaces configuration using environment varariable TF_WORKSPACE (#867)

Signed-off-by: Dmitry Kisler <admin@dkisler.com>
This commit is contained in:
Dmitry Kisler 2023-11-16 18:43:47 +01:00 committed by GitHub
parent 7e1be68264
commit c5170df91a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 381 additions and 470 deletions

View File

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

View File

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

View File

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

View File

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

View File

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