mirror of
https://github.com/opentffoundation/opentf.git
synced 2026-04-18 00:02:05 -04:00
Normally, `terraform output` refreshes and reads the entire state in the command package before pulling output values out of it. This doesn't give Terraform Cloud the opportunity to apply the read state outputs org permission and instead applies the read state versions permission. I decided to expand the state manager interface to provide a separate GetRootOutputValues function in order to give the cloud backend a more nuanced opportunity to fetch just the outputs. This required moving state Refresh/Read code that was previously in the command into the shared backend state as well as the filesystem state packages.
445 lines
13 KiB
Go
445 lines
13 KiB
Go
package cloud
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
tfe "github.com/hashicorp/go-tfe"
|
|
svchost "github.com/hashicorp/terraform-svchost"
|
|
"github.com/hashicorp/terraform-svchost/auth"
|
|
"github.com/hashicorp/terraform-svchost/disco"
|
|
"github.com/hashicorp/terraform/internal/backend"
|
|
"github.com/hashicorp/terraform/internal/configs"
|
|
"github.com/hashicorp/terraform/internal/configs/configschema"
|
|
"github.com/hashicorp/terraform/internal/httpclient"
|
|
"github.com/hashicorp/terraform/internal/providers"
|
|
"github.com/hashicorp/terraform/internal/states/remote"
|
|
"github.com/hashicorp/terraform/internal/terraform"
|
|
"github.com/hashicorp/terraform/internal/tfdiags"
|
|
"github.com/hashicorp/terraform/version"
|
|
"github.com/mitchellh/cli"
|
|
"github.com/zclconf/go-cty/cty"
|
|
|
|
backendLocal "github.com/hashicorp/terraform/internal/backend/local"
|
|
)
|
|
|
|
const (
|
|
testCred = "test-auth-token"
|
|
)
|
|
|
|
var (
|
|
tfeHost = svchost.Hostname(defaultHostname)
|
|
credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
|
|
tfeHost: {"token": testCred},
|
|
})
|
|
testBackendSingleWorkspaceName = "app-prod"
|
|
)
|
|
|
|
// mockInput is a mock implementation of terraform.UIInput.
|
|
type mockInput struct {
|
|
answers map[string]string
|
|
}
|
|
|
|
func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) {
|
|
v, ok := m.answers[opts.Id]
|
|
if !ok {
|
|
return "", fmt.Errorf("unexpected input request in test: %s", opts.Id)
|
|
}
|
|
if v == "wait-for-external-update" {
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(time.Minute):
|
|
}
|
|
}
|
|
delete(m.answers, opts.Id)
|
|
return v, nil
|
|
}
|
|
|
|
func testInput(t *testing.T, answers map[string]string) *mockInput {
|
|
return &mockInput{answers: answers}
|
|
}
|
|
|
|
func testBackendWithName(t *testing.T) (*Cloud, func()) {
|
|
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)),
|
|
}),
|
|
})
|
|
return testBackend(t, obj)
|
|
}
|
|
|
|
func testBackendWithTags(t *testing.T) (*Cloud, func()) {
|
|
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.NullVal(cty.String),
|
|
"tags": cty.SetVal(
|
|
[]cty.Value{
|
|
cty.StringVal("billing"),
|
|
},
|
|
),
|
|
}),
|
|
})
|
|
return testBackend(t, obj)
|
|
}
|
|
|
|
func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
|
|
obj := cty.ObjectVal(map[string]cty.Value{
|
|
"hostname": cty.NullVal(cty.String),
|
|
"organization": cty.StringVal("no-operations"),
|
|
"token": cty.NullVal(cty.String),
|
|
"workspaces": cty.ObjectVal(map[string]cty.Value{
|
|
"name": cty.StringVal(testBackendSingleWorkspaceName),
|
|
"tags": cty.NullVal(cty.Set(cty.String)),
|
|
}),
|
|
})
|
|
return testBackend(t, obj)
|
|
}
|
|
|
|
func testRemoteClient(t *testing.T) remote.Client {
|
|
b, bCleanup := testBackendWithName(t)
|
|
defer bCleanup()
|
|
|
|
raw, err := b.StateMgr(testBackendSingleWorkspaceName)
|
|
if err != nil {
|
|
t.Fatalf("error: %v", err)
|
|
}
|
|
|
|
return raw.(*State).Client
|
|
}
|
|
|
|
func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
|
|
b, cleanup := testBackendWithName(t)
|
|
|
|
// Get a new mock client to use for adding outputs
|
|
mc := NewMockClient()
|
|
|
|
mc.StateVersionOutputs.create("svo-abcd", &tfe.StateVersionOutput{
|
|
ID: "svo-abcd",
|
|
Value: "foobar",
|
|
Sensitive: true,
|
|
Type: "string",
|
|
Name: "sensitive_output",
|
|
DetailedType: "string",
|
|
})
|
|
|
|
mc.StateVersionOutputs.create("svo-zyxw", &tfe.StateVersionOutput{
|
|
ID: "svo-zyxw",
|
|
Value: "bazqux",
|
|
Type: "string",
|
|
Name: "nonsensitive_output",
|
|
DetailedType: "string",
|
|
})
|
|
|
|
var dt interface{}
|
|
var val interface{}
|
|
err := json.Unmarshal([]byte(`["object", {"foo":"string"}]`), &dt)
|
|
if err != nil {
|
|
t.Fatalf("could not unmarshal detailed type: %s", err)
|
|
}
|
|
err = json.Unmarshal([]byte(`{"foo":"bar"}`), &val)
|
|
if err != nil {
|
|
t.Fatalf("could not unmarshal value: %s", err)
|
|
}
|
|
mc.StateVersionOutputs.create("svo-efgh", &tfe.StateVersionOutput{
|
|
ID: "svo-efgh",
|
|
Value: val,
|
|
Type: "object",
|
|
Name: "object_output",
|
|
DetailedType: dt,
|
|
})
|
|
|
|
err = json.Unmarshal([]byte(`["list", "bool"]`), &dt)
|
|
if err != nil {
|
|
t.Fatalf("could not unmarshal detailed type: %s", err)
|
|
}
|
|
err = json.Unmarshal([]byte(`[true, false, true, true]`), &val)
|
|
if err != nil {
|
|
t.Fatalf("could not unmarshal value: %s", err)
|
|
}
|
|
mc.StateVersionOutputs.create("svo-ijkl", &tfe.StateVersionOutput{
|
|
ID: "svo-ijkl",
|
|
Value: val,
|
|
Type: "array",
|
|
Name: "list_output",
|
|
DetailedType: dt,
|
|
})
|
|
|
|
b.client.StateVersionOutputs = mc.StateVersionOutputs
|
|
|
|
return b, cleanup
|
|
}
|
|
|
|
func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) {
|
|
s := testServer(t)
|
|
b := New(testDisco(s))
|
|
|
|
// 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 len(confDiags) != 0 {
|
|
t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings())
|
|
}
|
|
|
|
// Get a new mock client.
|
|
mc := NewMockClient()
|
|
|
|
// Replace the services we use with our mock services.
|
|
b.CLI = cli.NewMockUi()
|
|
b.client.Applies = mc.Applies
|
|
b.client.ConfigurationVersions = mc.ConfigurationVersions
|
|
b.client.CostEstimates = mc.CostEstimates
|
|
b.client.Organizations = mc.Organizations
|
|
b.client.Plans = mc.Plans
|
|
b.client.PolicyChecks = mc.PolicyChecks
|
|
b.client.Runs = mc.Runs
|
|
b.client.StateVersions = mc.StateVersions
|
|
b.client.StateVersionOutputs = mc.StateVersionOutputs
|
|
b.client.Variables = mc.Variables
|
|
b.client.Workspaces = mc.Workspaces
|
|
|
|
// Set local to a local test backend.
|
|
b.local = testLocalBackend(t, b)
|
|
b.input = true
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create the organization.
|
|
_, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
|
|
Name: tfe.String(b.organization),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("error: %v", err)
|
|
}
|
|
|
|
// Create the default workspace if required.
|
|
if b.WorkspaceMapping.Name != "" {
|
|
_, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{
|
|
Name: tfe.String(b.WorkspaceMapping.Name),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("error: %v", err)
|
|
}
|
|
}
|
|
|
|
return b, s.Close
|
|
}
|
|
|
|
// testUnconfiguredBackend is used for testing the configuration of the backend
|
|
// with the mock client
|
|
func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
|
|
s := testServer(t)
|
|
b := New(testDisco(s))
|
|
|
|
// Normally, the client is created during configuration, but the configuration uses the
|
|
// client to read entitlements.
|
|
var err error
|
|
b.client, err = tfe.NewClient(&tfe.Config{
|
|
Token: "fake-token",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Get a new mock client.
|
|
mc := NewMockClient()
|
|
|
|
// Replace the services we use with our mock services.
|
|
b.CLI = cli.NewMockUi()
|
|
b.client.Applies = mc.Applies
|
|
b.client.ConfigurationVersions = mc.ConfigurationVersions
|
|
b.client.CostEstimates = mc.CostEstimates
|
|
b.client.Organizations = mc.Organizations
|
|
b.client.Plans = mc.Plans
|
|
b.client.PolicyChecks = mc.PolicyChecks
|
|
b.client.Runs = mc.Runs
|
|
b.client.StateVersions = mc.StateVersions
|
|
b.client.Variables = mc.Variables
|
|
b.client.Workspaces = mc.Workspaces
|
|
|
|
// Set local to a local test backend.
|
|
b.local = testLocalBackend(t, b)
|
|
|
|
return b, s.Close
|
|
}
|
|
|
|
func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced {
|
|
b := backendLocal.NewWithBackend(cloud)
|
|
|
|
// Add a test provider to the local backend.
|
|
p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{
|
|
ResourceTypes: map[string]*configschema.Block{
|
|
"null_resource": {
|
|
Attributes: map[string]*configschema.Attribute{
|
|
"id": {Type: cty.String, Computed: true},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
|
|
"id": cty.StringVal("yes"),
|
|
})}
|
|
|
|
return b
|
|
}
|
|
|
|
// testServer returns a started *httptest.Server used for local testing with the default set of
|
|
// request handlers.
|
|
func testServer(t *testing.T) *httptest.Server {
|
|
return testServerWithHandlers(testDefaultRequestHandlers)
|
|
}
|
|
|
|
// testServerWithHandlers returns a started *httptest.Server with the given set of request handlers
|
|
// overriding any default request handlers (testDefaultRequestHandlers).
|
|
func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server {
|
|
mux := http.NewServeMux()
|
|
for route, handler := range handlers {
|
|
mux.HandleFunc(route, handler)
|
|
}
|
|
for route, handler := range testDefaultRequestHandlers {
|
|
if handlers[route] == nil {
|
|
mux.HandleFunc(route, handler)
|
|
}
|
|
}
|
|
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
// testDefaultRequestHandlers is a map of request handlers intended to be used in a request
|
|
// multiplexer for a test server. A caller may use testServerWithHandlers to start a server with
|
|
// this base set of routes, and override a particular route for whatever edge case is being tested.
|
|
var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){
|
|
// Respond to service discovery calls.
|
|
"/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
io.WriteString(w, `{
|
|
"tfe.v2": "/api/v2/",
|
|
}`)
|
|
},
|
|
|
|
// Respond to service version constraints calls.
|
|
"/v1/versions/": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
io.WriteString(w, fmt.Sprintf(`{
|
|
"service": "%s",
|
|
"product": "terraform",
|
|
"minimum": "0.1.0",
|
|
"maximum": "10.0.0"
|
|
}`, path.Base(r.URL.Path)))
|
|
},
|
|
|
|
// Respond to pings to get the API version header.
|
|
"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("TFP-API-Version", "2.5")
|
|
},
|
|
|
|
// Respond to the initial query to read the hashicorp org entitlements.
|
|
"/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/vnd.api+json")
|
|
io.WriteString(w, `{
|
|
"data": {
|
|
"id": "org-GExadygjSbKP8hsY",
|
|
"type": "entitlement-sets",
|
|
"attributes": {
|
|
"operations": true,
|
|
"private-module-registry": true,
|
|
"sentinel": true,
|
|
"state-storage": true,
|
|
"teams": true,
|
|
"vcs-integrations": true
|
|
}
|
|
}
|
|
}`)
|
|
},
|
|
|
|
// Respond to the initial query to read the no-operations org entitlements.
|
|
"/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/vnd.api+json")
|
|
io.WriteString(w, `{
|
|
"data": {
|
|
"id": "org-ufxa3y8jSbKP8hsT",
|
|
"type": "entitlement-sets",
|
|
"attributes": {
|
|
"operations": false,
|
|
"private-module-registry": true,
|
|
"sentinel": true,
|
|
"state-storage": true,
|
|
"teams": true,
|
|
"vcs-integrations": true
|
|
}
|
|
}
|
|
}`)
|
|
},
|
|
|
|
// All tests that are assumed to pass will use the hashicorp organization,
|
|
// so for all other organization requests we will return a 404.
|
|
"/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(404)
|
|
io.WriteString(w, `{
|
|
"errors": [
|
|
{
|
|
"status": "404",
|
|
"title": "not found"
|
|
}
|
|
]
|
|
}`)
|
|
},
|
|
}
|
|
|
|
// testDisco returns a *disco.Disco mapping app.terraform.io and
|
|
// localhost to a local test server.
|
|
func testDisco(s *httptest.Server) *disco.Disco {
|
|
services := map[string]interface{}{
|
|
"tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL),
|
|
}
|
|
d := disco.NewWithCredentialsSource(credsSrc)
|
|
d.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
|
|
|
|
d.ForceHostServices(svchost.Hostname(defaultHostname), services)
|
|
d.ForceHostServices(svchost.Hostname("localhost"), services)
|
|
return d
|
|
}
|
|
|
|
type unparsedVariableValue struct {
|
|
value string
|
|
source terraform.ValueSourceType
|
|
}
|
|
|
|
func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
|
|
return &terraform.InputValue{
|
|
Value: cty.StringVal(v.value),
|
|
SourceType: v.source,
|
|
}, tfdiags.Diagnostics{}
|
|
}
|
|
|
|
// testVariable returns a backend.UnparsedVariableValue used for testing.
|
|
func testVariables(s terraform.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue {
|
|
vars := make(map[string]backend.UnparsedVariableValue, len(vs))
|
|
for _, v := range vs {
|
|
vars[v] = &unparsedVariableValue{
|
|
value: v,
|
|
source: s,
|
|
}
|
|
}
|
|
return vars
|
|
}
|