opentofu/internal/cloud/backend_test.go
Christian Mesh 2f5dcd5c0a
Integrate Encryption into State Backends (#1288)
Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
2024-03-04 09:25:14 -05:00

1314 lines
42 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloud
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
tfe "github.com/hashicorp/go-tfe"
version "github.com/hashicorp/go-version"
"github.com/opentofu/opentofu/internal/backend"
"github.com/opentofu/opentofu/internal/encryption"
"github.com/opentofu/opentofu/internal/tfdiags"
tfversion "github.com/opentofu/opentofu/version"
"github.com/zclconf/go-cty/cty"
backendLocal "github.com/opentofu/opentofu/internal/backend/local"
)
func TestCloud(t *testing.T) {
var _ backend.Enhanced = New(nil, encryption.StateEncryptionDisabled())
var _ backend.CLI = New(nil, encryption.StateEncryptionDisabled())
}
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_backendWithoutHost(t *testing.T) {
s := testServer(t)
b := New(testDisco(s), encryption.StateEncryptionDisabled())
obj := 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(testBackendSingleWorkspaceName),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
})
// Configure the backend so the client is created.
newObj, valDiags := b.PrepareConfig(obj)
if len(valDiags) != 0 {
t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings())
}
obj = newObj
confDiags := b.Configure(obj)
if !confDiags.HasErrors() {
t.Fatalf("testBackend: backend.Configure() should have failed")
}
if !strings.Contains(confDiags.Err().Error(), "Hostname is required for the cloud backend") {
t.Fatalf("testBackend: backend.Configure() should have failed with missing hostname error")
}
}
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)
}
}
func TestCloud_PrepareConfig(t *testing.T) {
cases := map[string]struct {
config cty.Value
expectedErr string
}{
"null organization": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"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"),
"workspaces": cty.NullVal(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"),
"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 present": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("org"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`,
},
"workspace: name and tags present": {
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.`,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
s := testServer(t)
b := New(testDisco(s), encryption.StateEncryptionDisabled())
// 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())
}
}
})
}
}
func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
cases := map[string]struct {
config cty.Value
vars map[string]string
expectedErr string
}{
"with no organization": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
"TF_CLOUD_ORGANIZATION": "example-org",
},
},
"with no organization attribute or env var": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]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("hashicorp"),
"workspaces": cty.NullVal(cty.String),
}),
vars: map[string]string{
"TF_WORKSPACE": "my-workspace",
},
},
"organization and workspace and project env var": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.NullVal(cty.String),
"workspaces": cty.NullVal(cty.String),
}),
vars: map[string]string{
"TF_CLOUD_ORGANIZATION": "hashicorp",
"TF_WORKSPACE": "my-workspace",
"TF_CLOUD_PROJECT": "example-project",
},
},
"with no project": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("organization"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
},
"with null project": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("organization"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
vars: map[string]string{
"TF_CLOUD_PROJECT": "example-project",
},
},
"with project env var overwrite config value": {
config: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("organization"),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.StringVal("project-name"),
}),
}),
vars: map[string]string{
"TF_CLOUD_PROJECT": "example-project",
},
},
"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 {
t.Run(name, func(t *testing.T) {
s := testServer(t)
b := New(testDisco(s), encryption.StateEncryptionDisabled())
for k, v := range tc.vars {
t.Setenv(k, v)
}
_, valDiags := b.PrepareConfig(tc.config)
if (valDiags.Err() == nil) != (tc.expectedErr == "") {
t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err())
}
if valDiags.Err() != nil {
if !strings.Contains(valDiags.Err().Error(), tc.expectedErr) {
t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err())
}
}
})
}
}
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: "tofu 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),
}),
}),
valErr: `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.StringVal(tfeHost),
"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), encryption.StateEncryptionDisabled())
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 the cloud backend.`
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.StringVal(tfeHost),
"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), encryption.StateEncryptionDisabled())
b.runningInAutomation = true
confDiags := b.Configure(config)
if confDiags.Err() == nil {
t.Fatalf("expected configure to error")
}
expected := `This version of cloud backend 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.StringVal(tfeHost),
"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 TF 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 TF version correctly, so this test isn't trustworthy anymore")
}
}
func TestCloud_setConfigurationFieldsHappyPath(t *testing.T) {
cases := map[string]struct {
obj cty.Value
envVars map[string]string
expectedHostname string
expectedOrganization string
expectedWorkspaceName string
expectedProjectName string
expectedWorkspaceTags map[string]struct{}
expectedForceLocal bool
}{
"with hostname, organization and tags set": {
obj: cty.ObjectVal(map[string]cty.Value{
"organization": cty.StringVal("opentofu"),
"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),
}),
}),
expectedHostname: "opentofu.org",
expectedOrganization: "opentofu",
expectedWorkspaceTags: map[string]struct{}{"foo": {}, "bar": {}},
},
"with hostname and workspace name set": {
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("prod"),
"tags": cty.NullVal(cty.Set(cty.String)),
"project": cty.NullVal(cty.String),
}),
}),
expectedHostname: "opentofu.org",
expectedWorkspaceName: "prod",
},
"with hostname and project name set": {
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("my-project"),
}),
}),
expectedHostname: "opentofu.org",
expectedProjectName: "my-project",
},
"with hostname and force local set (env var)": {
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),
}),
}),
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 {
t.Run(name, func(t *testing.T) {
for k, v := range tc.envVars {
t.Setenv(k, v)
}
b := &Cloud{}
errDiags := b.setConfigurationFields(tc.obj)
if errDiags.HasErrors() {
t.Fatalf("%s: unexpected validation result: %v", name, errDiags.Err())
}
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)
}
// read map of configured tags
gotTags := map[string]struct{}{}
for _, v := range b.WorkspaceMapping.Tags {
gotTags[v] = struct{}{}
}
if len(gotTags) != len(tc.expectedWorkspaceTags) {
t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags)
}
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)
}
}
})
}
}
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)
}
b := &Cloud{}
errDiags := b.setConfigurationFields(tc.obj)
if (tc.wantDetail != "" || tc.wantSummary != "") != errDiags.HasErrors() {
t.Fatalf("%s error expected", name)
}
gotSummary := errDiags[0].Description().Summary
if gotSummary != tc.wantSummary {
t.Fatalf("%s diagnostic summary mismatch, want: %s, got: %s", name, tc.wantSummary, gotSummary)
}
gotDetail := errDiags[0].Description().Detail
if gotDetail != tc.wantDetail {
t.Fatalf("%s diagnostic details mismatch, want: %s, got: %s", name, tc.wantDetail, gotDetail)
}
})
}
}
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 TF version "0.13.5" does not match local OpenTofu 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 TF 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 TF 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 TF version"; got != want {
t.Errorf("wrong summary: got %s, want %s", got, want)
}
wantDetail := "The local OpenTofu 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), encryption.StateEncryptionDisabled())
diag := b.Configure(cty.ObjectVal(map[string]cty.Value{
"hostname": cty.StringVal(tfeHost),
"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))
}
}