// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package cloud import ( "context" "fmt" "net/http" "os" "reflect" "sort" "strings" "testing" tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/tfdiags" tfversion "github.com/hashicorp/terraform/version" "github.com/zclconf/go-cty/cty" backendLocal "github.com/hashicorp/terraform/internal/backend/local" ) func TestCloud(t *testing.T) { var _ backend.Enhanced = New(nil) var _ backend.CLI = New(nil) } func TestCloud_backendWithName(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() workspaces, err := b.Workspaces() if err != nil { t.Fatalf("error: %v", err) } if len(workspaces) != 1 || workspaces[0] != testBackendSingleWorkspaceName { t.Fatalf("should only have a single configured workspace matching the configured 'name' strategy, but got: %#v", workspaces) } if _, err := b.StateMgr("foo"); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected fetching a state which is NOT the single configured workspace to have an ErrWorkspacesNotSupported error, but got: %v", err) } if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected deleting the single configured workspace name to result in an error, but got: %v", err) } if err := b.DeleteWorkspace("foo", true); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected deleting a workspace which is NOT the configured workspace name to result in an error, but got: %v", err) } } func TestCloud_backendWithTags(t *testing.T) { b, bCleanup := testBackendWithTags(t) defer bCleanup() backend.TestBackendStates(t, b) // Test pagination works for i := 0; i < 25; i++ { _, err := b.StateMgr(fmt.Sprintf("foo-%d", i+1)) if err != nil { t.Fatalf("error: %s", err) } } workspaces, err := b.Workspaces() if err != nil { t.Fatalf("error: %s", err) } actual := len(workspaces) if actual != 26 { t.Errorf("expected 26 workspaces (over one standard paginated response), got %d", actual) } } // Test b.PrepareConfig, which actually does very little; most real validation // happens later in resolveCloudConfig. func TestCloud_PrepareConfig(t *testing.T) { cases := map[string]struct { config cty.Value expectedErr string }{ "workspace: name and tags conflict": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), }, ), "project": cty.NullVal(cty.String), }), }), expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, }, "totally empty config block is ok": { 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.Object(map[string]cty.Type{ "name": cty.String, "tags": cty.Set(cty.String), "project": cty.String, })), }), }, } for name, tc := range cases { s := testServer(t) b := New(testDisco(s)) // Validate _, valDiags := b.PrepareConfig(tc.config) if tc.expectedErr != "" { if valDiags.Err() == nil { t.Fatalf("%s: expected validation error %q but didn't get one", name, tc.expectedErr) } else { actualErr := valDiags.Err().Error() if !strings.Contains(actualErr, tc.expectedErr) { t.Fatalf("%s: expected validation error %q but instead got %q", name, tc.expectedErr, actualErr) } } } else { if valDiags.Err() != nil { t.Fatalf("%s: expected validation to pass but got error %q", name, valDiags.Err().Error()) } } } } func TestCloud_configWithEnvVars(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: Terraform 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", }, expectedErr: `conflicts with TF_WORKSPACE environment variable`, }, "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: "Terraform 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: "my-project", // No error is raised, and workspace is still in its original // project, but the configured project for any future workspaces // created with `terraform workspace new` is unaffected. }, "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 { 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) } }) } } func TestCloud_config(t *testing.T) { cases := map[string]struct { config cty.Value confErr string valErr string }{ "with_a_non_tfe_host": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.StringVal("nontfe.local"), "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)), "project": cty.NullVal(cty.String), }), }), confErr: "Host nontfe.local does not provide a tfe service", }, // localhost advertises TFE services, but has no token in the credentials "without_a_token": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.StringVal("localhost"), "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)), "project": cty.NullVal(cty.String), }), }), confErr: "terraform login localhost", }, "with_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), }, ), "project": cty.NullVal(cty.String), }), }), }, "with_a_name": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.NullVal(cty.String), }), }), }, "without_a_name_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.NullVal(cty.String), }), }), confErr: `Missing workspace mapping strategy.`, }, "with_both_a_name_and_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), }, ), "project": cty.NullVal(cty.String), }), }), valErr: `Only one of workspace "tags" or "name" is allowed.`, }, "null config": { config: cty.NullVal(cty.EmptyObject), }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { b, cleanup := testUnconfiguredBackend(t) t.Cleanup(cleanup) // Validate _, valDiags := b.PrepareConfig(tc.config) if (valDiags.Err() != nil || tc.valErr != "") && (valDiags.Err() == nil || !strings.Contains(valDiags.Err().Error(), tc.valErr)) { t.Fatalf("unexpected validation result: %v", valDiags.Err()) } // Configure confDiags := b.Configure(tc.config) if (confDiags.Err() != nil || tc.confErr != "") && (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.confErr)) { t.Fatalf("unexpected configure result: %v", confDiags.Err()) } }) } } func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { config := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), }, ), "project": cty.NullVal(cty.String), }), }) handlers := map[string]func(http.ResponseWriter, *http.Request){ "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("TFP-API-Version", "2.4") }, } s := testServerWithHandlers(handlers) b := New(testDisco(s)) confDiags := b.Configure(config) if confDiags.Err() == nil { t.Fatalf("expected configure to error") } expected := `The 'cloud' option is not supported with this version of Terraform Enterprise.` if !strings.Contains(confDiags.Err().Error(), expected) { t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) } } func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) { config := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), }, ), "project": cty.NullVal(cty.String), }), }) handlers := map[string]func(http.ResponseWriter, *http.Request){ "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("TFP-API-Version", "2.4") }, } s := testServerWithHandlers(handlers) b := New(testDisco(s)) b.runningInAutomation = true confDiags := b.Configure(config) if confDiags.Err() == nil { t.Fatalf("expected configure to error") } expected := `This version of Terraform Cloud/Enterprise does not support the state mechanism attempting to be used by the platform. This should never happen.` if !strings.Contains(confDiags.Err().Error(), expected) { t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) } } func TestCloud_setUnavailableTerraformVersion(t *testing.T) { // go-tfe returns an error IRL if you try to set a Terraform version that's // not available in your TFC instance. To test this, tfe_client_mock errors if // you try to set any Terraform version for this specific workspace name. workspaceName := "unavailable-terraform-version" config := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("sometag"), }, ), "project": cty.NullVal(cty.String), }), }) b, _, bCleanup := testBackend(t, config, nil) defer bCleanup() // Make sure the workspace doesn't exist yet -- otherwise, we can't test what // happens when a workspace gets created. This is why we can't use "name" in // the backend config above, btw: if you do, testBackend() creates the default // workspace before we get a chance to do anything. _, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) if err != tfe.ErrResourceNotFound { t.Fatalf("the workspace we were about to try and create (%s/%s) already exists in the mocks somehow, so this test isn't trustworthy anymore", b.organization, workspaceName) } _, err = b.StateMgr(workspaceName) if err != nil { t.Fatalf("expected no error from StateMgr, despite not being able to set remote Terraform version: %#v", err) } // Make sure the workspace was created: workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) if err != nil { t.Fatalf("b.StateMgr() didn't actually create the desired workspace") } // Make sure our mocks still error as expected, using the same update function b.StateMgr() would call: _, err = b.client.Workspaces.UpdateByID( context.Background(), workspace.ID, tfe.WorkspaceUpdateOptions{TerraformVersion: tfe.String("1.1.0")}, ) if err == nil { t.Fatalf("the mocks aren't emulating a nonexistent remote Terraform version correctly, so this test isn't trustworthy anymore") } } func TestCloud_resolveCloudConfig(t *testing.T) { cases := map[string]struct { config cty.Value vars map[string]string expectedResult cloudConfig expectedErr string }{ "hostname/org/name/token/project all set": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.StringVal("app.staging.terraform.io"), "organization": cty.StringVal("examplecorp"), "token": cty.StringVal("password123"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.StringVal("networking"), }), }), expectedResult: cloudConfig{ hostname: "app.staging.terraform.io", organization: "examplecorp", token: "password123", workspaceMapping: WorkspaceMapping{ Name: "prod", Project: "networking", }, }, }, "totally empty config block": { 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.Object(map[string]cty.Type{ "name": cty.String, "tags": cty.Set(cty.String), "project": cty.String, })), }), vars: map[string]string{ "TF_CLOUD_HOSTNAME": "app.staging.terraform.io", "TF_CLOUD_ORGANIZATION": "examplecorp", "TF_WORKSPACE": "prod", "TF_CLOUD_PROJECT": "networking", }, expectedResult: cloudConfig{ hostname: "app.staging.terraform.io", organization: "examplecorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "prod", Project: "networking", }, }, }, "null organization": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.NullVal(cty.String), "hostname": cty.NullVal(cty.String), "token": 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), }), }), expectedErr: `Invalid or missing required argument: "organization" must be set in the cloud configuration or as an environment variable: TF_CLOUD_ORGANIZATION.`, }, "null workspace": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "hostname": cty.NullVal(cty.String), "token": cty.NullVal(cty.String), "workspaces": cty.NullVal(cty.Object(map[string]cty.Type{ "name": cty.String, "tags": cty.Set(cty.String), "project": cty.String, })), }), expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, }, "workspace: empty tags, name": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "hostname": cty.NullVal(cty.String), "token": 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), }), }), expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, }, "workspace: name and tags present": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "hostname": cty.NullVal(cty.String), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), }, ), "project": cty.NullVal(cty.String), }), }), expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, }, "organization from environment": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.NullVal(cty.String), "hostname": cty.NullVal(cty.String), "token": 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": "example-org", }, expectedResult: cloudConfig{ hostname: defaultHostname, organization: "example-org", token: "", workspaceMapping: WorkspaceMapping{ Name: "prod", }, }, }, "null workspace, TF_WORKSPACE present": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.NullVal(cty.String), "token": cty.NullVal(cty.String), "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": "my-workspace", }, expectedResult: cloudConfig{ hostname: defaultHostname, organization: "hashicorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "my-workspace", }, }, }, "organization and workspace and project env var": { 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.Object(map[string]cty.Type{ "name": cty.String, "tags": cty.Set(cty.String), "project": cty.String, })), }), vars: map[string]string{ "TF_CLOUD_ORGANIZATION": "hashicorp", "TF_WORKSPACE": "my-workspace", "TF_CLOUD_PROJECT": "example-project", }, expectedResult: cloudConfig{ hostname: defaultHostname, organization: "hashicorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "my-workspace", Project: "example-project", }, }, }, "with no project": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("examplecorp"), "hostname": cty.NullVal(cty.String), "token": 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), }), }), expectedResult: cloudConfig{ hostname: defaultHostname, organization: "examplecorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "prod", }, }, }, "with project from environment": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("examplecorp"), "hostname": cty.NullVal(cty.String), "token": 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_PROJECT": "example-project", }, expectedResult: cloudConfig{ hostname: defaultHostname, organization: "examplecorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "prod", Project: "example-project", }, }, }, "with both project env var and config value": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("examplecorp"), "hostname": cty.NullVal(cty.String), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "tags": cty.NullVal(cty.Set(cty.String)), "project": cty.StringVal("config-project"), }), }), vars: map[string]string{ "TF_CLOUD_PROJECT": "env-project", }, expectedResult: cloudConfig{ hostname: defaultHostname, organization: "examplecorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "prod", Project: "config-project", // config wins }, }, }, "with hostname not set, set to default hostname": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.NullVal(cty.String), "token": 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), }), }), expectedResult: cloudConfig{ hostname: defaultHostname, organization: "hashicorp", token: "", workspaceMapping: WorkspaceMapping{ Name: "prod", }, }, }, "with workspace tags set": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.StringVal("hashicorp.com"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), cty.StringVal("applications"), }, ), "project": cty.NullVal(cty.String), }), }), expectedResult: cloudConfig{ hostname: "hashicorp.com", organization: "hashicorp", token: "", workspaceMapping: WorkspaceMapping{ Tags: []string{"billing", "applications"}, }, }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { for k, v := range tc.vars { os.Setenv(k, v) } t.Cleanup(func() { for k := range tc.vars { os.Unsetenv(k) } }) result, diags := resolveCloudConfig(tc.config) // Validate either expected error or result, not both if tc.expectedErr != "" { if diags.Err() == nil { t.Fatalf("%s: expected validation error %q but didn't get one", name, tc.expectedErr) } else { actualErr := diags.Err().Error() if !strings.Contains(actualErr, tc.expectedErr) { t.Fatalf("%s: expected validation error %q but instead got %q", name, tc.expectedErr, actualErr) } } } else { // check result // Sort tags first to avoid flaking, since the slice order from // a cty.Set isn't guaranteed: sort.Strings(tc.expectedResult.workspaceMapping.Tags) sort.Strings(result.workspaceMapping.Tags) if !reflect.DeepEqual(tc.expectedResult, result) { t.Fatalf("%s: expected final config of %#v but instead got %#v", name, tc.expectedResult, result) } } }) } } func TestCloud_forceLocalBackend(t *testing.T) { b, cleanup := testUnconfiguredBackend(t) t.Cleanup(cleanup) os.Setenv("TF_FORCE_LOCAL_BACKEND", "true") t.Cleanup(func() { os.Unsetenv("TF_FORCE_LOCAL_BACKEND") }) obj := cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.NullVal(cty.String), "token": 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), }), }) obj, valDiags := b.PrepareConfig(obj) if valDiags.Err() != nil { t.Fatalf("unexpected validation result: %v", valDiags.Err()) } diags := b.Configure(obj) if diags.Err() != nil { t.Fatalf("unexpected configure result: %v", diags.Err()) } if b.forceLocal != true { t.Fatalf("expected force local backend to be set due to env var") } } func TestCloud_localBackend(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() local, ok := b.local.(*backendLocal.Local) if !ok { t.Fatalf("expected b.local to be \"*local.Local\", got: %T", b.local) } cloud, ok := local.Backend.(*Cloud) if !ok { t.Fatalf("expected local.Backend to be *cloud.Cloud, got: %T", cloud) } } func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { t.Fatalf("expected no error, got %v", err) } if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) } } func TestCloud_StateMgr_versionCheck(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() // Some fixed versions for testing with. This logic is a simple string // comparison, so we don't need many test cases. v0135 := version.Must(version.NewSemver("0.13.5")) v0140 := version.Must(version.NewSemver("0.14.0")) // Save original local version state and restore afterwards p := tfversion.Prerelease v := tfversion.Version s := tfversion.SemVer defer func() { tfversion.Prerelease = p tfversion.Version = v tfversion.SemVer = s }() // For this test, the local Terraform version is set to 0.14.0 tfversion.Prerelease = "" tfversion.Version = v0140.String() tfversion.SemVer = v0140 // Update the mock remote workspace Terraform version to match the local // Terraform version if _, err := b.client.Workspaces.Update( context.Background(), b.organization, b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(v0140.String()), }, ); err != nil { t.Fatalf("error: %v", err) } // This should succeed if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { t.Fatalf("expected no error, got %v", err) } // Now change the remote workspace to a different Terraform version if _, err := b.client.Workspaces.Update( context.Background(), b.organization, b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(v0135.String()), }, ); err != nil { t.Fatalf("error: %v", err) } // This should fail want := `Remote workspace Terraform version "0.13.5" does not match local Terraform version "0.14.0"` if _, err := b.StateMgr(testBackendSingleWorkspaceName); err.Error() != want { t.Fatalf("wrong error\n got: %v\nwant: %v", err.Error(), want) } } func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() v0140 := version.Must(version.NewSemver("0.14.0")) // Save original local version state and restore afterwards p := tfversion.Prerelease v := tfversion.Version s := tfversion.SemVer defer func() { tfversion.Prerelease = p tfversion.Version = v tfversion.SemVer = s }() // For this test, the local Terraform version is set to 0.14.0 tfversion.Prerelease = "" tfversion.Version = v0140.String() tfversion.SemVer = v0140 // Update the remote workspace to the pseudo-version "latest" if _, err := b.client.Workspaces.Update( context.Background(), b.organization, b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String("latest"), }, ); err != nil { t.Fatalf("error: %v", err) } // This should succeed despite not being a string match if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { t.Fatalf("expected no error, got %v", err) } } func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { testCases := []struct { local string remote string executionMode string wantErr bool }{ {"0.13.5", "0.13.5", "agent", false}, {"0.14.0", "0.13.5", "remote", true}, {"0.14.0", "0.13.5", "local", false}, {"0.14.0", "0.14.1", "remote", false}, {"0.14.0", "1.0.99", "remote", false}, {"0.14.0", "1.1.0", "remote", false}, {"0.14.0", "1.3.0", "remote", true}, {"1.2.0", "1.2.99", "remote", false}, {"1.2.0", "1.3.0", "remote", true}, {"0.15.0", "latest", "remote", false}, {"1.1.5", "~> 1.1.1", "remote", false}, {"1.1.5", "> 1.1.0, < 1.3.0", "remote", false}, {"1.1.5", "~> 1.0.1", "remote", true}, // pre-release versions are comparable within their pre-release stage (dev, // alpha, beta), but not comparable to different stages and not comparable // to final releases. {"1.1.0-beta1", "1.1.0-beta1", "remote", false}, {"1.1.0-beta1", "~> 1.1.0-beta", "remote", false}, {"1.1.0", "~> 1.1.0-beta", "remote", true}, {"1.1.0-beta1", "~> 1.1.0-dev", "remote", true}, } for _, tc := range testCases { t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() local := version.Must(version.NewSemver(tc.local)) // Save original local version state and restore afterwards p := tfversion.Prerelease v := tfversion.Version s := tfversion.SemVer defer func() { tfversion.Prerelease = p tfversion.Version = v tfversion.SemVer = s }() // Override local version as specified tfversion.Prerelease = "" tfversion.Version = local.String() tfversion.SemVer = local // Update the mock remote workspace Terraform version to the // specified remote version if _, err := b.client.Workspaces.Update( context.Background(), b.organization, b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ ExecutionMode: &tc.executionMode, TerraformVersion: tfe.String(tc.remote), }, ); err != nil { t.Fatalf("error: %v", err) } diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) if tc.wantErr { if len(diags) != 1 { t.Fatal("expected diag, but none returned") } if got := diags.Err().Error(); !strings.Contains(got, "Incompatible Terraform version") { t.Fatalf("unexpected error: %s", got) } } else { if len(diags) != 0 { t.Fatalf("unexpected diags: %s", diags.Err()) } } }) } } func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() // Attempting to check the version against a workspace which doesn't exist // should result in no errors diags := b.VerifyWorkspaceTerraformVersion("invalid-workspace") if len(diags) != 0 { t.Fatalf("unexpected error: %s", diags.Err()) } // Use a special workspace ID to trigger a 500 error, which should result // in a failed check diags = b.VerifyWorkspaceTerraformVersion("network-error") if len(diags) != 1 { t.Fatal("expected diag, but none returned") } if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Workspace read failed") { t.Fatalf("unexpected error: %s", got) } // Update the mock remote workspace Terraform version to an invalid version if _, err := b.client.Workspaces.Update( context.Background(), b.organization, b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String("1.0.cheetarah"), }, ); err != nil { t.Fatalf("error: %v", err) } diags = b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) if len(diags) != 1 { t.Fatal("expected diag, but none returned") } if got := diags.Err().Error(); !strings.Contains(got, "Incompatible Terraform version: The remote workspace specified") { t.Fatalf("unexpected error: %s", got) } } func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() // If the ignore flag is set, the behaviour changes b.IgnoreVersionConflict() // Different local & remote versions to cause an error local := version.Must(version.NewSemver("0.14.0")) remote := version.Must(version.NewSemver("0.13.5")) // Save original local version state and restore afterwards p := tfversion.Prerelease v := tfversion.Version s := tfversion.SemVer defer func() { tfversion.Prerelease = p tfversion.Version = v tfversion.SemVer = s }() // Override local version as specified tfversion.Prerelease = "" tfversion.Version = local.String() tfversion.SemVer = local // Update the mock remote workspace Terraform version to the // specified remote version if _, err := b.client.Workspaces.Update( context.Background(), b.organization, b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(remote.String()), }, ); err != nil { t.Fatalf("error: %v", err) } diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) if len(diags) != 1 { t.Fatal("expected diag, but none returned") } if got, want := diags[0].Severity(), tfdiags.Warning; got != want { t.Errorf("wrong severity: got %#v, want %#v", got, want) } if got, want := diags[0].Description().Summary, "Incompatible Terraform version"; got != want { t.Errorf("wrong summary: got %s, want %s", got, want) } wantDetail := "The local Terraform version (0.14.0) does not meet the version requirements for remote workspace hashicorp/app-prod (0.13.5)." if got := diags[0].Description().Detail; got != wantDetail { t.Errorf("wrong summary: got %s, want %s", got, wantDetail) } } func TestCloudBackend_DeleteWorkspace_SafeAndForce(t *testing.T) { b, bCleanup := testBackendWithTags(t) defer bCleanup() safeDeleteWorkspaceName := "safe-delete-workspace" forceDeleteWorkspaceName := "force-delete-workspace" _, err := b.StateMgr(safeDeleteWorkspaceName) if err != nil { t.Fatalf("error: %s", err) } _, err = b.StateMgr(forceDeleteWorkspaceName) if err != nil { t.Fatalf("error: %s", err) } // sanity check that the mock now contains two workspaces wl, err := b.Workspaces() if err != nil { t.Fatalf("error fetching workspace names: %v", err) } if len(wl) != 2 { t.Fatalf("expected 2 workspaced but got %d", len(wl)) } c := context.Background() safeDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, safeDeleteWorkspaceName) if err != nil { t.Fatalf("error fetching workspace: %v", err) } // Lock a workspace so that it should fail to be safe deleted _, err = b.client.Workspaces.Lock(context.Background(), safeDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")}) if err != nil { t.Fatalf("error locking workspace: %v", err) } err = b.DeleteWorkspace(safeDeleteWorkspaceName, false) if err == nil { t.Fatalf("workspace should have failed to safe delete") } // unlock the workspace and confirm that safe-delete now works _, err = b.client.Workspaces.Unlock(context.Background(), safeDeleteWorkspace.ID) if err != nil { t.Fatalf("error unlocking workspace: %v", err) } err = b.DeleteWorkspace(safeDeleteWorkspaceName, false) if err != nil { t.Fatalf("error safe deleting workspace: %v", err) } // lock a workspace and then confirm that force deleting it works forceDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, forceDeleteWorkspaceName) if err != nil { t.Fatalf("error fetching workspace: %v", err) } _, err = b.client.Workspaces.Lock(context.Background(), forceDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")}) if err != nil { t.Fatalf("error locking workspace: %v", err) } err = b.DeleteWorkspace(forceDeleteWorkspaceName, true) if err != nil { t.Fatalf("error force deleting workspace: %v", err) } } func TestCloudBackend_DeleteWorkspace_DoesNotExist(t *testing.T) { b, bCleanup := testBackendWithTags(t) defer bCleanup() err := b.DeleteWorkspace("non-existent-workspace", false) if err != nil { t.Fatalf("expected deleting a workspace which does not exist to succeed") } } func TestCloud_ServiceDiscoveryAliases(t *testing.T) { s := testServer(t) b := New(testDisco(s)) diag := b.Configure(cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), // Forces aliasing to test server "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)), "project": cty.NullVal(cty.String), }), })) if diag.HasErrors() { t.Fatalf("expected no diagnostic errors, got %s", diag.Err()) } aliases, err := b.ServiceDiscoveryAliases() if err != nil { t.Fatalf("expected no errors, got %s", err) } if len(aliases) != 1 { t.Fatalf("expected 1 alias but got %d", len(aliases)) } }