Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cloud): terraform output should adhere to read state outputs authorization #30769

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -41,7 +41,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.0
github.com/hashicorp/go-tfe v1.0.0
github.com/hashicorp/go-tfe v1.1.1-0.20220329184205-bc3385badc7d
github.com/hashicorp/go-uuid v1.0.2
github.com/hashicorp/go-version v1.3.0
github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Expand Up @@ -415,8 +415,8 @@ github.com/hashicorp/go-slug v0.8.0/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.0.0 h1:CmwoHrOs7WJfD/yEmVjJ65+dyKeVRrgvRHBLVSQQ6Ks=
github.com/hashicorp/go-tfe v1.0.0/go.mod h1:tJF/OlAXzVbmjiimAPLplSLgwg6kZDUOy0MzHuMwvF4=
github.com/hashicorp/go-tfe v1.1.1-0.20220329184205-bc3385badc7d h1:AXRz9uSle4xMabr79f4Uz7SrkMBISTzD5HF+dvw+ZY4=
github.com/hashicorp/go-tfe v1.1.1-0.20220329184205-bc3385badc7d/go.mod h1:tJF/OlAXzVbmjiimAPLplSLgwg6kZDUOy0MzHuMwvF4=
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 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
Expand Down
4 changes: 4 additions & 0 deletions internal/backend/local/backend_local_test.go
Expand Up @@ -220,6 +220,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
3 changes: 1 addition & 2 deletions internal/cloud/backend.go
Expand Up @@ -19,7 +19,6 @@ import (
"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"
Expand Down Expand Up @@ -608,7 +607,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
2 changes: 1 addition & 1 deletion internal/cloud/backend_state_test.go
Expand Up @@ -33,7 +33,7 @@ func TestRemoteClient_stateLock(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
118 changes: 118 additions & 0 deletions internal/cloud/state.go
@@ -0,0 +1,118 @@
package cloud

import (
"context"
"encoding/json"
"fmt"

tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
)

// 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
}

// 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)
}

// 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 {
value, err := s.cloudOutputToCtyValue(ctx, output)
if err != nil {
return nil, fmt.Errorf("Could not interpret output value as simple json: %w", err)
}

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

return result, nil
}

func (s *State) cloudOutputToCtyValue(ctx context.Context, output *tfe.StateVersionOutput) (*cty.Value, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about why this method returns a pointer to a value, instead of a value directly. It's more common to return a cty.Value, and I can't find any other cases where we use a pointer.

value := output.Value
// If an output is sensitive, the API requires that we fetch this output individually to get the value
// Terraform will decline to reveal the value under some circumstances, but we must provide it to callers
if output.Sensitive {
svo, err := s.Client.client.StateVersionOutputs.Read(ctx, output.ID)
if err != nil {
return nil, fmt.Errorf("Could not read sensitive output %s: %w", output.ID, err)
}

value = svo.Value
}

buf, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("Could not interpret output value: %w", err)
}

v := ctyjson.SimpleJSONValue{}
err = v.UnmarshalJSON(buf)

if err != nil {
return nil, fmt.Errorf("Could not interpret output value as simple json: %w", err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might consider hiding some of the detail of what's happening here. A message like "Could not parse output value" doesn't get so far into the weeds, as cty's "simple json" is not something that I'd expect a Terraform user to understand.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding to that: cty's error messages for JSON parsing are intentionally written with the author of the JSON object as the intended audience, so it is possible that an error message here would use some JSON-specific terminology anyway, but I do agree with Alisdair that 'simple JSON" is very much a cty API convenience feature and not anything an end-user should ever see.

Presumably if we ever get into this error case it reflects a bug in Terraform Cloud rather than something the end-user could address, so I imagine it doesn't really make a lot of difference in the end. In situations like this elsewhere we have included statements like "This is a bug in Terraform; please report it!" but I think we're too deep in the stack for it to make sense to do that here, since we'd typically add that annotation as part of converting the error to a diagnostic, rather than directly in the error object.


One thing to watch out for here is that the SimpleJSONValue helper is a wrapper around using ImpliedType and then decoding using that implied type.

In particular, that means that this will lose information compared to what would happen if using the values directly from states.OutputValue.

For example, given a root module output value declared like this:

output "example" {
  type = toset("hello", "world")
}

states.OutputValue.Value would preserve the exact type information and appear as cty.SetVal([]string{"hello", "world"}), but because JSON doesn't have any set type the naive ImpliedType/SimpleJSONValue approach would instead yield cty.TupleVal([]string{"hello", "world"}).

For many purposes that distinction isn't super important, but if these values ever get into a context where users can evaluate HCL expressions against them then this could appear as a breaking change for any expression that is relying on the differences between set, tuple, and list types.

For example:

data.terraform_remote_state.example.outputs.foo == toset([])

== can return true only if both operands are of the same type, so the above could potentially return true for an output value which returns an empty set when accessing the state directly, but would always return false if we silently convert the value to be an empty tuple value instead.

I expect there are other situtions where behavior would be slightly different but not obviously wrong if we silently convert a set to a tuple.

If we do let this leak into the language then it could create subtly incorrect behavior (without an explicit error message explaining why) for someone who started off using some other backend and then switched to Terraform Cloud later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This made total sense and I appreciate that you explained it. I became concerned that we were losing this type information somewhere in Terraform Cloud, because all my set values were described as array by the API. Sure enough, I found that the terraform-state-parser service within TFE interprets all values using ImpliedType. I can send you a link if you are interested.

Even if I do the right thing it will still be broken and set type outputs will be displayed as lists (and maps as objects!). I think the right thing to do is to modify cloudOutputToCtyValue so that it can express all terraform types, and then modify the API to do the same... but that is a slightly heavier lift and won't be backwards compatible. It doesn't seem like these output types are very well documented in the API and I've got some concerns about suddenly changing "array" to "set", everything else being equal. 😣

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does unfortunately seem like that API is at the "wrong" level of abstraction for this use-case, indeed -- I guess it was written with UI concerns in mind, where the server is encapsulating certain logic to simplify the UI code, rather than to be used directly as data inside Terraform Core.

I don't really know what to suggest here other than to add to the table the idea that maybe it's warranted to have a separate API for the raw data underneath the abstraction; literally a copy of the verbatim outputs object from the underlying state, perhaps, so that additions to the format in future will just pass through without needing to alter the intermediaries and we'd thus be guaranteed that fetching the output state and fetching the whole state would always yield identical results?

Copy link
Contributor Author

@brandonc brandonc Apr 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may make sense to just add the underlying type information (with json state version info!?) to the current API, under a separate key. If I had the type information and the value expressed as json, I think I can use ctyjson.UnmarshalType and ctyjson.Unmarshal to get the correct values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as the API layer isn't doing any other transforms to the value I think you're right that having a true JSON representation of the type as recorded in the state would be sufficient to address the concern I originally raised here.

It does still mean that if we were to add anything else inside the output state in future we'd need to modify the Terraform Cloud API to pass it through and Terraform Enterprise users who have upgraded Terraform CLI but not yet updated Terraform Cloud would potentially see that information seem to get quietly "lost" in the API layer, so something which passes it through verbatim would be a more robust answer to allow us to evolve these things more gracefully in future (by avoiding tight coupling) but I will concede that it's a separate concern than just the type information being incorrect. 😁

}

return &v.Value, err
}
72 changes: 72 additions & 0 deletions internal/cloud/state_test.go
@@ -0,0 +1,72 @@
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,
},
}

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)
}
}
}
29 changes: 28 additions & 1 deletion internal/cloud/testing.go
Expand Up @@ -117,7 +117,33 @@ func testRemoteClient(t *testing.T) remote.Client {
t.Fatalf("error: %v", err)
}

return raw.(*remote.State).Client
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",
})

mc.StateVersionOutputs.create("svo-zyxw", &tfe.StateVersionOutput{
ID: "svo-zyxw",
Value: "bazqux",
Type: "string",
Name: "nonsensitive_output",
})

b.client.StateVersionOutputs = mc.StateVersionOutputs

return b, cleanup
}

func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) {
Expand Down Expand Up @@ -149,6 +175,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) {
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

Expand Down
45 changes: 45 additions & 0 deletions internal/cloud/tfe_client_mock.go
Expand Up @@ -29,6 +29,7 @@ type MockClient struct {
PolicyChecks *MockPolicyChecks
Runs *MockRuns
StateVersions *MockStateVersions
StateVersionOutputs *MockStateVersionOutputs
Variables *MockVariables
Workspaces *MockWorkspaces
}
Expand All @@ -43,6 +44,7 @@ func NewMockClient() *MockClient {
c.PolicyChecks = newMockPolicyChecks(c)
c.Runs = newMockRuns(c)
c.StateVersions = newMockStateVersions(c)
c.StateVersionOutputs = newMockStateVersionOutputs(c)
c.Variables = newMockVariables(c)
c.Workspaces = newMockWorkspaces(c)
return c
Expand Down Expand Up @@ -1025,6 +1027,49 @@ func (m *MockStateVersions) ListOutputs(ctx context.Context, svID string, option
panic("not implemented")
}

type MockStateVersionOutputs struct {
client *MockClient
outputs map[string]*tfe.StateVersionOutput
}

func newMockStateVersionOutputs(client *MockClient) *MockStateVersionOutputs {
return &MockStateVersionOutputs{
client: client,
outputs: make(map[string]*tfe.StateVersionOutput),
}
}

// This is a helper function in order to create mocks to be read later
func (m *MockStateVersionOutputs) create(id string, svo *tfe.StateVersionOutput) {
m.outputs[id] = svo
}

func (m *MockStateVersionOutputs) Read(ctx context.Context, outputID string) (*tfe.StateVersionOutput, error) {
result, ok := m.outputs[outputID]
if !ok {
return nil, tfe.ErrResourceNotFound
}

return result, nil
}

func (m *MockStateVersionOutputs) ReadCurrent(ctx context.Context, workspaceID string) (*tfe.StateVersionOutputsList, error) {
svl := &tfe.StateVersionOutputsList{}
for _, sv := range m.outputs {
svl.Items = append(svl.Items, sv)
}

svl.Pagination = &tfe.Pagination{
CurrentPage: 1,
NextPage: 1,
PreviousPage: 1,
TotalPages: 1,
TotalCount: len(svl.Items),
}

return svl, nil
}

type MockVariables struct {
client *MockClient
workspaces map[string]*tfe.VariableList
Expand Down
13 changes: 4 additions & 9 deletions internal/command/output.go
Expand Up @@ -82,17 +82,12 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu
return nil, diags
}

if err := stateStore.RefreshState(); err != nil {
diags = diags.Append(fmt.Errorf("Failed to load state: %s", err))
return nil, diags
}

state := stateStore.State()
if state == nil {
state = states.NewState()
output, err := stateStore.GetRootOutputValues()
if err != nil {
return nil, diags.Append(err)
}

return state.RootModule().OutputValues, nil
return output, diags
}

func (c *OutputCommand) Help() string {
Expand Down
13 changes: 13 additions & 0 deletions internal/states/remote/state.go
Expand Up @@ -46,6 +46,19 @@ func (s *State) State() *states.State {
return s.state.DeepCopy()
}

func (s *State) GetRootOutputValues() (map[string]*states.OutputValue, error) {
if err := s.RefreshState(); err != nil {
return nil, fmt.Errorf("Failed to load state: %s", err)
}

state := s.State()
if state == nil {
state = states.NewState()
}

return state.RootModule().OutputValues, nil
}

// StateForMigration is part of our implementation of statemgr.Migrator.
func (s *State) StateForMigration() *statefile.File {
s.mu.Lock()
Expand Down