github.com/opentofu/opentofu@v1.7.1/internal/cloud/testing.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cloud
     7  
     8  import (
     9  	"bytes"
    10  	"context"
    11  	"encoding/json"
    12  	"fmt"
    13  	"io"
    14  	"net/http"
    15  	"net/http/httptest"
    16  	"net/url"
    17  	"os"
    18  	"path"
    19  	"strconv"
    20  	"testing"
    21  	"time"
    22  
    23  	tfe "github.com/hashicorp/go-tfe"
    24  	svchost "github.com/hashicorp/terraform-svchost"
    25  	"github.com/hashicorp/terraform-svchost/auth"
    26  	"github.com/hashicorp/terraform-svchost/disco"
    27  	"github.com/mitchellh/cli"
    28  	"github.com/mitchellh/colorstring"
    29  	"github.com/zclconf/go-cty/cty"
    30  
    31  	"github.com/opentofu/opentofu/internal/backend"
    32  	"github.com/opentofu/opentofu/internal/configs"
    33  	"github.com/opentofu/opentofu/internal/configs/configschema"
    34  	"github.com/opentofu/opentofu/internal/encryption"
    35  	"github.com/opentofu/opentofu/internal/httpclient"
    36  	"github.com/opentofu/opentofu/internal/providers"
    37  	"github.com/opentofu/opentofu/internal/states"
    38  	"github.com/opentofu/opentofu/internal/states/statefile"
    39  	"github.com/opentofu/opentofu/internal/tfdiags"
    40  	"github.com/opentofu/opentofu/internal/tofu"
    41  	"github.com/opentofu/opentofu/version"
    42  
    43  	backendLocal "github.com/opentofu/opentofu/internal/backend/local"
    44  )
    45  
    46  const (
    47  	testCred = "test-auth-token"
    48  )
    49  
    50  var (
    51  	tfeHost  = "app.terraform.io"
    52  	credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
    53  		svchost.Hostname(tfeHost): {"token": testCred},
    54  	})
    55  	testBackendSingleWorkspaceName = "app-prod"
    56  	defaultTFCPing                 = map[string]func(http.ResponseWriter, *http.Request){
    57  		"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
    58  			w.Header().Set("Content-Type", "application/json")
    59  			w.Header().Set("TFP-API-Version", "2.5")
    60  			w.Header().Set("TFP-AppName", "Terraform Cloud")
    61  		},
    62  	}
    63  )
    64  
    65  func skipIfTFENotEnabled(t *testing.T) {
    66  	if os.Getenv("TF_TFC_TEST") == "" {
    67  		t.Skip("this test accesses " + tfeHost + "; set TF_TFC_TEST=1 to run it")
    68  	}
    69  }
    70  
    71  // mockInput is a mock implementation of tofu.UIInput.
    72  type mockInput struct {
    73  	answers map[string]string
    74  }
    75  
    76  func (m *mockInput) Input(ctx context.Context, opts *tofu.InputOpts) (string, error) {
    77  	v, ok := m.answers[opts.Id]
    78  	if !ok {
    79  		return "", fmt.Errorf("unexpected input request in test: %s", opts.Id)
    80  	}
    81  	if v == "wait-for-external-update" {
    82  		select {
    83  		case <-ctx.Done():
    84  		case <-time.After(time.Minute):
    85  		}
    86  	}
    87  	delete(m.answers, opts.Id)
    88  	return v, nil
    89  }
    90  
    91  func testInput(t *testing.T, answers map[string]string) *mockInput {
    92  	skipIfTFENotEnabled(t)
    93  	return &mockInput{answers: answers}
    94  }
    95  
    96  func testBackendWithName(t *testing.T) (*Cloud, func()) {
    97  	b, _, c := testBackendAndMocksWithName(t)
    98  	return b, c
    99  }
   100  
   101  func testBackendAndMocksWithName(t *testing.T) (*Cloud, *MockClient, func()) {
   102  	obj := cty.ObjectVal(map[string]cty.Value{
   103  		"hostname":     cty.StringVal(tfeHost),
   104  		"organization": cty.StringVal("hashicorp"),
   105  		"token":        cty.NullVal(cty.String),
   106  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   107  			"name":    cty.StringVal(testBackendSingleWorkspaceName),
   108  			"tags":    cty.NullVal(cty.Set(cty.String)),
   109  			"project": cty.NullVal(cty.String),
   110  		}),
   111  	})
   112  	return testBackend(t, obj, defaultTFCPing)
   113  }
   114  
   115  func testBackendWithTags(t *testing.T) (*Cloud, func()) {
   116  	obj := cty.ObjectVal(map[string]cty.Value{
   117  		"hostname":     cty.StringVal(tfeHost),
   118  		"organization": cty.StringVal("hashicorp"),
   119  		"token":        cty.NullVal(cty.String),
   120  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   121  			"name": cty.NullVal(cty.String),
   122  			"tags": cty.SetVal(
   123  				[]cty.Value{
   124  					cty.StringVal("billing"),
   125  				},
   126  			),
   127  			"project": cty.NullVal(cty.String),
   128  		}),
   129  	})
   130  	b, _, c := testBackend(t, obj, nil)
   131  	return b, c
   132  }
   133  
   134  func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
   135  	obj := cty.ObjectVal(map[string]cty.Value{
   136  		"hostname":     cty.StringVal(tfeHost),
   137  		"organization": cty.StringVal("no-operations"),
   138  		"token":        cty.NullVal(cty.String),
   139  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   140  			"name":    cty.StringVal(testBackendSingleWorkspaceName),
   141  			"tags":    cty.NullVal(cty.Set(cty.String)),
   142  			"project": cty.NullVal(cty.String),
   143  		}),
   144  	})
   145  	b, _, c := testBackend(t, obj, nil)
   146  	return b, c
   147  }
   148  
   149  func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) {
   150  	obj := cty.ObjectVal(map[string]cty.Value{
   151  		"hostname":     cty.StringVal(tfeHost),
   152  		"organization": cty.StringVal("hashicorp"),
   153  		"token":        cty.NullVal(cty.String),
   154  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   155  			"name":    cty.StringVal(testBackendSingleWorkspaceName),
   156  			"tags":    cty.NullVal(cty.Set(cty.String)),
   157  			"project": cty.NullVal(cty.String),
   158  		}),
   159  	})
   160  	b, _, c := testBackend(t, obj, handlers)
   161  	return b, c
   162  }
   163  
   164  func testCloudState(t *testing.T) *State {
   165  	b, bCleanup := testBackendWithName(t)
   166  	defer bCleanup()
   167  
   168  	raw, err := b.StateMgr(testBackendSingleWorkspaceName)
   169  	if err != nil {
   170  		t.Fatalf("error: %v", err)
   171  	}
   172  
   173  	return raw.(*State)
   174  }
   175  
   176  func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
   177  	b, cleanup := testBackendWithName(t)
   178  
   179  	// Get a new mock client to use for adding outputs
   180  	mc := NewMockClient()
   181  
   182  	mc.StateVersionOutputs.create("svo-abcd", &tfe.StateVersionOutput{
   183  		ID:           "svo-abcd",
   184  		Value:        "foobar",
   185  		Sensitive:    true,
   186  		Type:         "string",
   187  		Name:         "sensitive_output",
   188  		DetailedType: "string",
   189  	})
   190  
   191  	mc.StateVersionOutputs.create("svo-zyxw", &tfe.StateVersionOutput{
   192  		ID:           "svo-zyxw",
   193  		Value:        "bazqux",
   194  		Type:         "string",
   195  		Name:         "nonsensitive_output",
   196  		DetailedType: "string",
   197  	})
   198  
   199  	var dt interface{}
   200  	var val interface{}
   201  	err := json.Unmarshal([]byte(`["object", {"foo":"string"}]`), &dt)
   202  	if err != nil {
   203  		t.Fatalf("could not unmarshal detailed type: %s", err)
   204  	}
   205  	err = json.Unmarshal([]byte(`{"foo":"bar"}`), &val)
   206  	if err != nil {
   207  		t.Fatalf("could not unmarshal value: %s", err)
   208  	}
   209  	mc.StateVersionOutputs.create("svo-efgh", &tfe.StateVersionOutput{
   210  		ID:           "svo-efgh",
   211  		Value:        val,
   212  		Type:         "object",
   213  		Name:         "object_output",
   214  		DetailedType: dt,
   215  	})
   216  
   217  	err = json.Unmarshal([]byte(`["list", "bool"]`), &dt)
   218  	if err != nil {
   219  		t.Fatalf("could not unmarshal detailed type: %s", err)
   220  	}
   221  	err = json.Unmarshal([]byte(`[true, false, true, true]`), &val)
   222  	if err != nil {
   223  		t.Fatalf("could not unmarshal value: %s", err)
   224  	}
   225  	mc.StateVersionOutputs.create("svo-ijkl", &tfe.StateVersionOutput{
   226  		ID:           "svo-ijkl",
   227  		Value:        val,
   228  		Type:         "array",
   229  		Name:         "list_output",
   230  		DetailedType: dt,
   231  	})
   232  
   233  	b.client.StateVersionOutputs = mc.StateVersionOutputs
   234  
   235  	return b, cleanup
   236  }
   237  
   238  func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, *MockClient, func()) {
   239  	skipIfTFENotEnabled(t)
   240  	var s *httptest.Server
   241  	if handlers != nil {
   242  		s = testServerWithHandlers(handlers)
   243  	} else {
   244  		s = testServer(t)
   245  	}
   246  	b := New(testDisco(s), encryption.StateEncryptionDisabled())
   247  
   248  	// Configure the backend so the client is created.
   249  	newObj, valDiags := b.PrepareConfig(obj)
   250  	if len(valDiags) != 0 {
   251  		t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings())
   252  	}
   253  	obj = newObj
   254  
   255  	confDiags := b.Configure(obj)
   256  	if len(confDiags) != 0 {
   257  		t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings())
   258  	}
   259  
   260  	// Get a new mock client.
   261  	mc := NewMockClient()
   262  
   263  	// Replace the services we use with our mock services.
   264  	b.CLI = cli.NewMockUi()
   265  	b.client.Applies = mc.Applies
   266  	b.client.ConfigurationVersions = mc.ConfigurationVersions
   267  	b.client.CostEstimates = mc.CostEstimates
   268  	b.client.Organizations = mc.Organizations
   269  	b.client.Plans = mc.Plans
   270  	b.client.TaskStages = mc.TaskStages
   271  	b.client.PolicySetOutcomes = mc.PolicySetOutcomes
   272  	b.client.PolicyChecks = mc.PolicyChecks
   273  	b.client.Runs = mc.Runs
   274  	b.client.RunEvents = mc.RunEvents
   275  	b.client.StateVersions = mc.StateVersions
   276  	b.client.StateVersionOutputs = mc.StateVersionOutputs
   277  	b.client.Variables = mc.Variables
   278  	b.client.Workspaces = mc.Workspaces
   279  
   280  	// Set local to a local test backend.
   281  	b.local = testLocalBackend(t, b)
   282  	b.input = true
   283  
   284  	baseURL, err := url.Parse("https://" + tfeHost)
   285  	if err != nil {
   286  		t.Fatalf("testBackend: failed to parse base URL for client")
   287  	}
   288  	baseURL.Path = "/api/v2/"
   289  
   290  	readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) {
   291  		return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
   292  	}
   293  
   294  	ctx := context.Background()
   295  
   296  	// Create the organization.
   297  	_, err = b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
   298  		Name: tfe.String(b.organization),
   299  	})
   300  	if err != nil {
   301  		t.Fatalf("error: %v", err)
   302  	}
   303  
   304  	// Create the default workspace if required.
   305  	if b.WorkspaceMapping.Name != "" {
   306  		_, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{
   307  			Name: tfe.String(b.WorkspaceMapping.Name),
   308  		})
   309  		if err != nil {
   310  			t.Fatalf("error: %v", err)
   311  		}
   312  	}
   313  
   314  	return b, mc, s.Close
   315  }
   316  
   317  // testUnconfiguredBackend is used for testing the configuration of the backend
   318  // with the mock client
   319  func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
   320  	skipIfTFENotEnabled(t)
   321  
   322  	s := testServer(t)
   323  	b := New(testDisco(s), encryption.StateEncryptionDisabled())
   324  
   325  	// Normally, the client is created during configuration, but the configuration uses the
   326  	// client to read entitlements.
   327  	var err error
   328  	b.client, err = tfe.NewClient(&tfe.Config{
   329  		Token: "fake-token",
   330  	})
   331  	if err != nil {
   332  		t.Fatal(err)
   333  	}
   334  
   335  	// Get a new mock client.
   336  	mc := NewMockClient()
   337  
   338  	// Replace the services we use with our mock services.
   339  	b.CLI = cli.NewMockUi()
   340  	b.client.Applies = mc.Applies
   341  	b.client.ConfigurationVersions = mc.ConfigurationVersions
   342  	b.client.CostEstimates = mc.CostEstimates
   343  	b.client.Organizations = mc.Organizations
   344  	b.client.Plans = mc.Plans
   345  	b.client.PolicySetOutcomes = mc.PolicySetOutcomes
   346  	b.client.PolicyChecks = mc.PolicyChecks
   347  	b.client.Runs = mc.Runs
   348  	b.client.RunEvents = mc.RunEvents
   349  	b.client.StateVersions = mc.StateVersions
   350  	b.client.StateVersionOutputs = mc.StateVersionOutputs
   351  	b.client.Variables = mc.Variables
   352  	b.client.Workspaces = mc.Workspaces
   353  
   354  	baseURL, err := url.Parse("https://" + tfeHost)
   355  	if err != nil {
   356  		t.Fatalf("testBackend: failed to parse base URL for client")
   357  	}
   358  	baseURL.Path = "/api/v2/"
   359  
   360  	readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) {
   361  		return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
   362  	}
   363  
   364  	// Set local to a local test backend.
   365  	b.local = testLocalBackend(t, b)
   366  
   367  	return b, s.Close
   368  }
   369  
   370  func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced {
   371  	skipIfTFENotEnabled(t)
   372  
   373  	b := backendLocal.NewWithBackend(cloud, nil)
   374  
   375  	// Add a test provider to the local backend.
   376  	p := backendLocal.TestLocalProvider(t, b, "null", providers.ProviderSchema{
   377  		ResourceTypes: map[string]providers.Schema{
   378  			"null_resource": {
   379  				Block: &configschema.Block{
   380  					Attributes: map[string]*configschema.Attribute{
   381  						"id": {Type: cty.String, Computed: true},
   382  					},
   383  				},
   384  			},
   385  		},
   386  	})
   387  	p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
   388  		"id": cty.StringVal("yes"),
   389  	})}
   390  
   391  	return b
   392  }
   393  
   394  // testServer returns a started *httptest.Server used for local testing with the default set of
   395  // request handlers.
   396  func testServer(t *testing.T) *httptest.Server {
   397  	skipIfTFENotEnabled(t)
   398  
   399  	return testServerWithHandlers(testDefaultRequestHandlers)
   400  }
   401  
   402  // testServerWithHandlers returns a started *httptest.Server with the given set of request handlers
   403  // overriding any default request handlers (testDefaultRequestHandlers).
   404  func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server {
   405  	mux := http.NewServeMux()
   406  	for route, handler := range handlers {
   407  		mux.HandleFunc(route, handler)
   408  	}
   409  	for route, handler := range testDefaultRequestHandlers {
   410  		if handlers[route] == nil {
   411  			mux.HandleFunc(route, handler)
   412  		}
   413  	}
   414  
   415  	return httptest.NewServer(mux)
   416  }
   417  
   418  func testServerWithSnapshotsEnabled(t *testing.T, enabled bool) *httptest.Server {
   419  	skipIfTFENotEnabled(t)
   420  
   421  	var serverURL string
   422  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   423  		t.Log(r.Method, r.URL.String())
   424  
   425  		if r.URL.Path == "/state-json" {
   426  			t.Log("pretending to be Archivist")
   427  			fakeState := states.NewState()
   428  			fakeStateFile := statefile.New(fakeState, "boop", 1)
   429  			var buf bytes.Buffer
   430  			statefile.Write(fakeStateFile, &buf, encryption.StateEncryptionDisabled())
   431  			respBody := buf.Bytes()
   432  			w.Header().Set("content-type", "application/json")
   433  			w.Header().Set("content-length", strconv.FormatInt(int64(len(respBody)), 10))
   434  			w.WriteHeader(http.StatusOK)
   435  			w.Write(respBody)
   436  			return
   437  		}
   438  
   439  		if r.URL.Path == "/api/ping" {
   440  			t.Log("pretending to be Ping")
   441  			w.WriteHeader(http.StatusNoContent)
   442  			return
   443  		}
   444  
   445  		fakeBody := map[string]any{
   446  			"data": map[string]any{
   447  				"type": "state-versions",
   448  				"id":   GenerateID("sv-"),
   449  				"attributes": map[string]any{
   450  					"hosted-state-download-url": serverURL + "/state-json",
   451  					"hosted-state-upload-url":   serverURL + "/state-json",
   452  				},
   453  			},
   454  		}
   455  		fakeBodyRaw, err := json.Marshal(fakeBody)
   456  		if err != nil {
   457  			t.Fatal(err)
   458  		}
   459  
   460  		w.Header().Set("content-type", tfe.ContentTypeJSONAPI)
   461  		w.Header().Set("content-length", strconv.FormatInt(int64(len(fakeBodyRaw)), 10))
   462  
   463  		switch r.Method {
   464  		case "POST":
   465  			t.Log("pretending to be Create a State Version")
   466  			if enabled {
   467  				w.Header().Set("x-terraform-snapshot-interval", "300")
   468  			}
   469  			w.WriteHeader(http.StatusAccepted)
   470  		case "GET":
   471  			t.Log("pretending to be Fetch the Current State Version for a Workspace")
   472  			if enabled {
   473  				w.Header().Set("x-terraform-snapshot-interval", "300")
   474  			}
   475  			w.WriteHeader(http.StatusOK)
   476  		case "PUT":
   477  			t.Log("pretending to be Archivist")
   478  		default:
   479  			t.Fatal("don't know what API operation this was supposed to be")
   480  		}
   481  
   482  		w.WriteHeader(http.StatusOK)
   483  		w.Write(fakeBodyRaw)
   484  	}))
   485  	serverURL = server.URL
   486  	return server
   487  }
   488  
   489  // testDefaultRequestHandlers is a map of request handlers intended to be used in a request
   490  // multiplexer for a test server. A caller may use testServerWithHandlers to start a server with
   491  // this base set of routes, and override a particular route for whatever edge case is being tested.
   492  var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){
   493  	// Respond to service discovery calls.
   494  	"/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) {
   495  		w.Header().Set("Content-Type", "application/json")
   496  		io.WriteString(w, `{
   497    "tfe.v2": "/api/v2/",
   498  }`)
   499  	},
   500  
   501  	// Respond to service version constraints calls.
   502  	"/v1/versions/": func(w http.ResponseWriter, r *http.Request) {
   503  		w.Header().Set("Content-Type", "application/json")
   504  		io.WriteString(w, fmt.Sprintf(`{
   505    "service": "%s",
   506    "product": "terraform",
   507    "minimum": "0.1.0",
   508    "maximum": "10.0.0"
   509  }`, path.Base(r.URL.Path)))
   510  	},
   511  
   512  	// Respond to pings to get the API version header.
   513  	"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
   514  		w.Header().Set("Content-Type", "application/json")
   515  		w.Header().Set("TFP-API-Version", "2.5")
   516  	},
   517  
   518  	// Respond to the initial query to read the hashicorp org entitlements.
   519  	"/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
   520  		w.Header().Set("Content-Type", "application/vnd.api+json")
   521  		io.WriteString(w, `{
   522    "data": {
   523      "id": "org-GExadygjSbKP8hsY",
   524      "type": "entitlement-sets",
   525      "attributes": {
   526        "operations": true,
   527        "private-module-registry": true,
   528        "sentinel": true,
   529        "state-storage": true,
   530        "teams": true,
   531        "vcs-integrations": true
   532      }
   533    }
   534  }`)
   535  	},
   536  
   537  	// Respond to the initial query to read the no-operations org entitlements.
   538  	"/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
   539  		w.Header().Set("Content-Type", "application/vnd.api+json")
   540  		io.WriteString(w, `{
   541    "data": {
   542      "id": "org-ufxa3y8jSbKP8hsT",
   543      "type": "entitlement-sets",
   544      "attributes": {
   545        "operations": false,
   546        "private-module-registry": true,
   547        "sentinel": true,
   548        "state-storage": true,
   549        "teams": true,
   550        "vcs-integrations": true
   551      }
   552    }
   553  }`)
   554  	},
   555  
   556  	// All tests that are assumed to pass will use the hashicorp organization,
   557  	// so for all other organization requests we will return a 404.
   558  	"/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) {
   559  		w.WriteHeader(404)
   560  		io.WriteString(w, `{
   561    "errors": [
   562      {
   563        "status": "404",
   564        "title": "not found"
   565      }
   566    ]
   567  }`)
   568  	},
   569  }
   570  
   571  func mockColorize() *colorstring.Colorize {
   572  	colors := make(map[string]string)
   573  	for k, v := range colorstring.DefaultColors {
   574  		colors[k] = v
   575  	}
   576  	colors["purple"] = "38;5;57"
   577  
   578  	return &colorstring.Colorize{
   579  		Colors:  colors,
   580  		Disable: false,
   581  		Reset:   true,
   582  	}
   583  }
   584  
   585  func mockSROWorkspace(t *testing.T, b *Cloud, workspaceName string) {
   586  	_, err := b.client.Workspaces.Update(context.Background(), "hashicorp", workspaceName, tfe.WorkspaceUpdateOptions{
   587  		StructuredRunOutputEnabled: tfe.Bool(true),
   588  		TerraformVersion:           tfe.String("1.4.0"),
   589  	})
   590  	if err != nil {
   591  		t.Fatalf("Error enabling SRO on workspace %s: %v", workspaceName, err)
   592  	}
   593  }
   594  
   595  // testDisco returns a *disco.Disco mapping app.terraform.io and
   596  // localhost to a local test server.
   597  func testDisco(s *httptest.Server) *disco.Disco {
   598  	services := map[string]interface{}{
   599  		"tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL),
   600  	}
   601  	d := disco.NewWithCredentialsSource(credsSrc)
   602  	d.SetUserAgent(httpclient.OpenTofuUserAgent(version.String()))
   603  
   604  	d.ForceHostServices(svchost.Hostname(tfeHost), services)
   605  	d.ForceHostServices(svchost.Hostname("localhost"), services)
   606  	d.ForceHostServices(svchost.Hostname("nontfe.local"), nil)
   607  	return d
   608  }
   609  
   610  type unparsedVariableValue struct {
   611  	value  string
   612  	source tofu.ValueSourceType
   613  }
   614  
   615  func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*tofu.InputValue, tfdiags.Diagnostics) {
   616  	return &tofu.InputValue{
   617  		Value:      cty.StringVal(v.value),
   618  		SourceType: v.source,
   619  	}, tfdiags.Diagnostics{}
   620  }
   621  
   622  // testVariable returns a backend.UnparsedVariableValue used for testing.
   623  func testVariables(s tofu.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue {
   624  	vars := make(map[string]backend.UnparsedVariableValue, len(vs))
   625  	for _, v := range vs {
   626  		vars[v] = &unparsedVariableValue{
   627  			value:  v,
   628  			source: s,
   629  		}
   630  	}
   631  	return vars
   632  }