Skip to content

Commit

Permalink
Merge pull request #31507 from hashicorp/brandonc/output_cloud_reads
Browse files Browse the repository at this point in the history
`terraform output` should adhere to Terraform Cloud authorization
  • Loading branch information
brandonc committed Jul 28, 2022
2 parents 93c525b + 50d48c6 commit c62e20c
Show file tree
Hide file tree
Showing 19 changed files with 480 additions and 29 deletions.
4 changes: 2 additions & 2 deletions go.mod
Expand Up @@ -40,7 +40,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/hashicorp/go-tfe v1.5.0
github.com/hashicorp/go-tfe v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f
Expand Down Expand Up @@ -177,7 +177,7 @@ require (
go.opencensus.io v0.23.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e // indirect
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Expand Up @@ -375,8 +375,8 @@ github.com/hashicorp/go-slug v0.9.1/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41
github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-tfe v1.5.0 h1:MtABkqH2s6lRFl8HaGt0qESLGAyrmMAFfecsEm+13K8=
github.com/hashicorp/go-tfe v1.5.0/go.mod h1:E8a90lC4kjU5Lc2c0D+SnWhUuyuoCIVm4Ewzv3jCD3A=
github.com/hashicorp/go-tfe v1.6.0 h1:lRfyTVLBP1njo2wShE9FimALzVZBfOqMGNuBdsor38w=
github.com/hashicorp/go-tfe v1.6.0/go.mod h1:E8a90lC4kjU5Lc2c0D+SnWhUuyuoCIVm4Ewzv3jCD3A=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
Expand Down Expand Up @@ -858,8 +858,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
Expand Down
7 changes: 6 additions & 1 deletion internal/backend/local/backend_local_test.go
Expand Up @@ -6,6 +6,8 @@ import (
"path/filepath"
"testing"

"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
Expand All @@ -20,7 +22,6 @@ import (
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)

func TestLocalRun(t *testing.T) {
Expand Down Expand Up @@ -220,6 +221,10 @@ func (s *stateStorageThatFailsRefresh) State() *states.State {
return nil
}

func (s *stateStorageThatFailsRefresh) GetRootOutputValues() (map[string]*states.OutputValue, error) {
return nil, fmt.Errorf("unimplemented")
}

func (s *stateStorageThatFailsRefresh) WriteState(*states.State) error {
return fmt.Errorf("unimplemented")
}
Expand Down
12 changes: 6 additions & 6 deletions internal/cloud/backend.go
Expand Up @@ -16,18 +16,18 @@ import (
version "github.com/hashicorp/go-version"
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"

"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
tfversion "github.com/hashicorp/terraform/version"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"

backendLocal "github.com/hashicorp/terraform/internal/backend/local"
)
Expand Down Expand Up @@ -628,7 +628,7 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) {
runID: os.Getenv("TFE_RUN_ID"),
}

return &remote.State{Client: client}, nil
return NewState(client), nil
}

// Operation implements backend.Enhanced.
Expand Down
4 changes: 2 additions & 2 deletions internal/cloud/backend_state_test.go
Expand Up @@ -30,7 +30,7 @@ func TestRemoteClient_stateVersionCreated(t *testing.T) {
t.Fatalf("error: %v", err)
}

client := raw.(*remote.State).Client
client := raw.(*State).Client

err = client.Put(([]byte)(`
{
Expand Down Expand Up @@ -78,7 +78,7 @@ func TestRemoteClient_TestRemoteLocks(t *testing.T) {
t.Fatalf("expected no error, got %v", err)
}

remote.TestRemoteLocks(t, s1.(*remote.State).Client, s2.(*remote.State).Client)
remote.TestRemoteLocks(t, s1.(*State).Client, s2.(*State).Client)
}

func TestRemoteClient_withRunID(t *testing.T) {
Expand Down
160 changes: 160 additions & 0 deletions internal/cloud/state.go
@@ -0,0 +1,160 @@
package cloud

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"

"github.com/hashicorp/go-tfe"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"

"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/hashicorp/terraform/internal/states/statemgr"
)

// State is similar to remote State and delegates to it, except in the case of output values,
// which use a separate methodology that ensures the caller is authorized to read cloud
// workspace outputs.
type State struct {
Client *remoteClient

delegate remote.State
}

var ErrStateVersionUnauthorizedUpgradeState = errors.New(strings.TrimSpace(`
You are not authorized to read the full state version containing outputs.
State versions created by terraform v1.3.0 and newer do not require this level
of authorization and therefore this error can usually be fixed by upgrading the
remote state version.
`))

// Proof that cloud State is a statemgr.Persistent interface
var _ statemgr.Persistent = (*State)(nil)

func NewState(client *remoteClient) *State {
return &State{
Client: client,
delegate: remote.State{Client: client},
}
}

// State delegates calls to read State to the remote State
func (s *State) State() *states.State {
return s.delegate.State()
}

// Lock delegates calls to lock state to the remote State
func (s *State) Lock(info *statemgr.LockInfo) (string, error) {
return s.delegate.Lock(info)
}

// Unlock delegates calls to unlock state to the remote State
func (s *State) Unlock(id string) error {
return s.delegate.Unlock(id)
}

// RefreshState delegates calls to refresh State to the remote State
func (s *State) RefreshState() error {
return s.delegate.RefreshState()
}

// RefreshState delegates calls to refresh State to the remote State
func (s *State) PersistState() error {
return s.delegate.PersistState()
}

// WriteState delegates calls to write State to the remote State
func (s *State) WriteState(state *states.State) error {
return s.delegate.WriteState(state)
}

func (s *State) fallbackReadOutputsFromFullState() (map[string]*states.OutputValue, error) {
log.Printf("[DEBUG] falling back to reading full state")

if err := s.RefreshState(); err != nil {
return nil, fmt.Errorf("failed to load state: %w", err)
}

state := s.State()
if state == nil {
// We know that there is supposed to be state (and this is not simply a new workspace
// without state) because the fallback is only invoked when outputs are present but
// detailed types are not available.
return nil, ErrStateVersionUnauthorizedUpgradeState
}

return state.RootModule().OutputValues, nil
}

// GetRootOutputValues fetches output values from Terraform Cloud
func (s *State) GetRootOutputValues() (map[string]*states.OutputValue, error) {
ctx := context.Background()

so, err := s.Client.client.StateVersionOutputs.ReadCurrent(ctx, s.Client.workspace.ID)

if err != nil {
return nil, fmt.Errorf("could not read state version outputs: %w", err)
}

result := make(map[string]*states.OutputValue)

for _, output := range so.Items {
if output.DetailedType == nil {
// If there is no detailed type information available, this state was probably created
// with a version of terraform < 1.3.0. In this case, we'll eject completely from this
// function and fall back to the old behavior of reading the entire state file, which
// requires a higher level of authorization.
return s.fallbackReadOutputsFromFullState()
}

if output.Sensitive {
// Since this is a sensitive value, the output must be requested explicitly in order to
// read its value, which is assumed to be present by callers
sensitiveOutput, err := s.Client.client.StateVersionOutputs.Read(ctx, output.ID)
if err != nil {
return nil, fmt.Errorf("could not read state version output %s: %w", output.ID, err)
}
output.Value = sensitiveOutput.Value
}

cval, err := tfeOutputToCtyValue(*output)
if err != nil {
return nil, fmt.Errorf("could not decode output %s (ID %s)", output.Name, output.ID)
}

result[output.Name] = &states.OutputValue{
Value: cval,
Sensitive: output.Sensitive,
}
}

return result, nil
}

// tfeOutputToCtyValue decodes a combination of TFE output value and detailed-type to create a
// cty value that is suitable for use in terraform.
func tfeOutputToCtyValue(output tfe.StateVersionOutput) (cty.Value, error) {
var result cty.Value
bufType, err := json.Marshal(output.DetailedType)
if err != nil {
return result, fmt.Errorf("could not marshal output %s type: %w", output.ID, err)
}

var ctype cty.Type
err = ctype.UnmarshalJSON(bufType)
if err != nil {
return result, fmt.Errorf("could not interpret output %s type: %w", output.ID, err)
}

result, err = gocty.ToCtyValue(output.Value, ctype)
if err != nil {
return result, fmt.Errorf("could not interpret value %v as type %s for output %s: %w", result, ctype.FriendlyName(), output.ID, err)
}

return result, nil
}
83 changes: 83 additions & 0 deletions internal/cloud/state_test.go
@@ -0,0 +1,83 @@
package cloud

import (
"testing"

"github.com/hashicorp/go-tfe"

"github.com/hashicorp/terraform/internal/states/statemgr"
)

func TestState_impl(t *testing.T) {
var _ statemgr.Reader = new(State)
var _ statemgr.Writer = new(State)
var _ statemgr.Persister = new(State)
var _ statemgr.Refresher = new(State)
var _ statemgr.OutputReader = new(State)
var _ statemgr.Locker = new(State)
}

type ExpectedOutput struct {
Name string
Sensitive bool
IsNull bool
}

func TestState_GetRootOutputValues(t *testing.T) {
b, bCleanup := testBackendWithOutputs(t)
defer bCleanup()

client := &remoteClient{
client: b.client,
workspace: &tfe.Workspace{
ID: "ws-abcd",
},
}

state := NewState(client)
outputs, err := state.GetRootOutputValues()

if err != nil {
t.Fatalf("error returned from GetRootOutputValues: %s", err)
}

cases := []ExpectedOutput{
{
Name: "sensitive_output",
Sensitive: true,
IsNull: false,
},
{
Name: "nonsensitive_output",
Sensitive: false,
IsNull: false,
},
{
Name: "object_output",
Sensitive: false,
IsNull: false,
},
{
Name: "list_output",
Sensitive: false,
IsNull: false,
},
}

if len(outputs) != len(cases) {
t.Errorf("Expected %d item but %d were returned", len(cases), len(outputs))
}

for _, testCase := range cases {
so, ok := outputs[testCase.Name]
if !ok {
t.Fatalf("Expected key %s but it was not found", testCase.Name)
}
if so.Value.IsNull() != testCase.IsNull {
t.Errorf("Key %s does not match null expectation %v", testCase.Name, testCase.IsNull)
}
if so.Sensitive != testCase.Sensitive {
t.Errorf("Key %s does not match sensitive expectation %v", testCase.Name, testCase.Sensitive)
}
}
}

0 comments on commit c62e20c

Please sign in to comment.