Skip to content

Commit 65b4b42

Browse files
authored
Fix inconsistent plan error (#218)
This is a fix for the inconsistent plan error. ### Background To expose outputs from a module, we need to account for the secretness of the outputs If you try and output a secret value without setting `sensitive: true` in the output The deployment will fail. We also need to be able to handle this dynamically since we won't know the secret status until after the operation, and we need to write the output definition when we create the file (before the operation). ### Previous Solution In #132 we added support for module outputs by using a `terraform_data` resource as a proxy for the output. This allowed us to account for the dynamic nature of the secret since we could determine the secret status of the value using the same method that we use for all other resource properties. This ran into an issue #198 where `terraform_data` resources cannot be used if the value may be "inconsistent". A very common case that is used in most modules is to define an output like this: ```hcl output "default_security_group_id" { description = "The ID of the security group created by default on VPC creation" value = try(aws_vpc.this[0].default_security_group_id, null) } ``` `try` is used because most modules will conditionally create resources and need to handle the case where the resource was not created and the value doesn't exist. This is a common case, but there may be other cases of this "inconsistent" data. ### New Solution This PR tries a new solution of using output values with a combination of the [nonsensitive](https://developer.hashicorp.com/terraform/language/functions/nonsensitive) and [issensitive](https://developer.hashicorp.com/terraform/language/functions/issensitive) functions. We can use the `issensitive` function to dynamically determine whether the value is sensitive or not. Unfortunately it is not possible to use functions in the `output.sensitive` field (e.g. `sensitive: "${issensitive(module.source_module.output_name1)}"` won't work). To work around this we use two outputs for each output value: - The first output is the actual value that we mark as non-sensitive to avoid the error - The second output is a boolean that indicates whether the value is a secret or not. ```json "output": { "name1": { "value": "${nonsensitive(module.source_module.output_name1)}" }, "internal_output_is_secret_name1": { "value": "${issensitive(module.source_module.output_name1)}" }, ... } ``` fixes #198
1 parent 089d6a9 commit 65b4b42

File tree

6 files changed

+157
-78
lines changed

6 files changed

+157
-78
lines changed

pkg/modprovider/module_component.go

-12
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,6 @@ func NewModuleComponentResource(
183183
var errs []error
184184

185185
plan.VisitResources(func(rp *tfsandbox.ResourcePlan) {
186-
if rp.IsInternalOutputResource() {
187-
// skip internal output resources which we created
188-
// so that we propagate outputs from module
189-
return
190-
}
191-
192186
resourceOptions := []pulumi.ResourceOption{
193187
pulumi.Parent(&component),
194188
}
@@ -233,12 +227,6 @@ func NewModuleComponentResource(
233227

234228
var errs []error
235229
tfState.VisitResources(func(rp *tfsandbox.ResourceState) {
236-
if rp.IsInternalOutputResource() {
237-
// skip internal output resources which we created
238-
// so that we propagate outputs from module
239-
return
240-
}
241-
242230
resourceOptions := []pulumi.ResourceOption{
243231
pulumi.Parent(&component),
244232
}

pkg/tfsandbox/details.go

+50-41
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package tfsandbox
1616

1717
import (
18+
"fmt"
1819
"strings"
1920

2021
tfjson "github.com/hashicorp/terraform-json"
@@ -188,43 +189,46 @@ func unknown() resource.PropertyValue {
188189
})
189190
}
190191

191-
// secretUnknown returns true if the value is a secret and the secret value is unknown.
192-
func secretUnknown(value resource.PropertyValue) bool {
193-
if value.IsSecret() {
194-
secret := value.SecretValue()
195-
if secret != nil && secret.Element.IsComputed() {
196-
return true
192+
// isInternalOutputResource returns true if the resource is an internal is_secret output
193+
// which is used to keep track of the secretness of the output.
194+
func isInternalOutputResource(name string) bool {
195+
return strings.HasPrefix(name, terraformIsSecretOutputPrefix)
196+
}
197+
198+
// outputIsSecret returns true if the output is a secret based on the value of the
199+
// corresponding is_secret output.
200+
func (p *Plan) outputIsSecret(outputName string) bool {
201+
isSecretKey := fmt.Sprintf("%s%s", terraformIsSecretOutputPrefix, outputName)
202+
if isSecretVal, ok := p.rawPlan.OutputChanges[isSecretKey]; ok {
203+
// If the value is unknown, just return false because we don't know the value
204+
// so secretness doesn't matter yet
205+
if afterUnknown, ok := isSecretVal.AfterUnknown.(bool); ok && afterUnknown {
206+
return false
197207
}
208+
return isSecretVal.After.(bool)
198209
}
199-
210+
contract.Failf("isSecret key %q not found in output changes", isSecretKey)
200211
return false
201212
}
202213

203-
// IsInternalOutputResource returns true if the resource is an internal output resource.
204-
// which is used to expose outputs from the source module in a way that maintains unknown-ness
205-
// and secretness of the outputs.
206-
func (p *ResourcePlan) IsInternalOutputResource() bool {
207-
return strings.HasPrefix(p.Name(), terraformDataResourcePrefix)
208-
}
209-
210214
// Outputs returns the outputs of a terraform plan as a Pulumi property map.
211215
func (p *Plan) Outputs() resource.PropertyMap {
212216
outputs := resource.PropertyMap{}
213-
p.Resources.VisitResources(func(res *ResourcePlan) {
214-
if res.IsInternalOutputResource() {
215-
withoutPrefix := strings.TrimPrefix(res.Name(), terraformDataResourcePrefix)
216-
outputKey := resource.PropertyKey(withoutPrefix)
217-
plannedValues := res.PlannedValues()
218-
if v, ok := plannedValues[resource.PropertyKey("input")]; ok {
219-
if secretUnknown(v) {
220-
// collapse secret unknowns into just unknown
221-
outputs[outputKey] = unknown()
222-
} else {
223-
outputs[outputKey] = v
224-
}
217+
for outputKey, output := range p.rawPlan.OutputChanges {
218+
if isInternalOutputResource(outputKey) {
219+
continue
220+
}
221+
key := resource.PropertyKey(outputKey)
222+
if afterUnknown, ok := output.AfterUnknown.(bool); ok && afterUnknown {
223+
outputs[key] = unknown()
224+
} else {
225+
val := resource.NewPropertyValueRepl(output.After, nil, replaceJSONNumberValue)
226+
if p.outputIsSecret(outputKey) {
227+
val = resource.MakeSecret(val)
225228
}
229+
outputs[key] = val
226230
}
227-
})
231+
}
228232
return outputs
229233
}
230234

@@ -260,26 +264,31 @@ func newState(rawState *tfjson.State) (*State, error) {
260264
}, nil
261265
}
262266

263-
// IsInternalOutputResource returns true if the resource is an internal output resource.
264-
// which is used to expose outputs from the source module in a way that maintains unknown-ness
265-
// and secretness of the outputs.
266-
func (s *ResourceState) IsInternalOutputResource() bool {
267-
return strings.HasPrefix(s.Name(), terraformDataResourcePrefix)
267+
// outputIsSecret returns true if the output is a secret based on the value of the
268+
// corresponding is_secret output.
269+
func (s *State) outputIsSecret(outputName string) bool {
270+
isSecretKey := fmt.Sprintf("%s%s", terraformIsSecretOutputPrefix, outputName)
271+
if isSecretVal, ok := s.rawState.Values.Outputs[isSecretKey]; ok {
272+
return isSecretVal.Value.(bool)
273+
}
274+
contract.Failf("isSecret key %q not found in output changes", isSecretKey)
275+
return false
268276
}
269277

270278
// Outputs returns the outputs of a terraform module state as a Pulumi property map.
271279
func (s *State) Outputs() resource.PropertyMap {
272280
outputs := resource.PropertyMap{}
273-
s.Resources.VisitResources(func(res *ResourceState) {
274-
if res.IsInternalOutputResource() {
275-
withoutPrefix := strings.TrimPrefix(res.Name(), terraformDataResourcePrefix)
276-
outputKey := resource.PropertyKey(withoutPrefix)
277-
attributeValues := res.AttributeValues()
278-
if v, ok := attributeValues[resource.PropertyKey("input")]; ok {
279-
outputs[outputKey] = v
280-
}
281+
for outputKey, output := range s.rawState.Values.Outputs {
282+
if isInternalOutputResource(outputKey) {
283+
continue
281284
}
282-
})
285+
key := resource.PropertyKey(outputKey)
286+
val := resource.NewPropertyValueRepl(output.Value, nil, replaceJSONNumberValue)
287+
if s.outputIsSecret(outputKey) {
288+
val = resource.MakeSecret(val)
289+
}
290+
outputs[key] = val
291+
}
283292

284293
return outputs
285294
}

pkg/tfsandbox/state_test.go

+60
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ import (
1818
"bytes"
1919
"context"
2020
"encoding/json"
21+
"io"
2122
"path/filepath"
2223
"testing"
2324

2425
"github.com/stretchr/testify/require"
2526

2627
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
28+
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
2729
)
2830

2931
func TestState(t *testing.T) {
@@ -178,3 +180,61 @@ func TestStateMatchesPlan(t *testing.T) {
178180
})
179181
}
180182
}
183+
184+
type testLogger struct {
185+
r io.Writer
186+
}
187+
188+
func (t *testLogger) Log(_ LogLevel, input string) {
189+
_, err := t.r.Write([]byte(input))
190+
contract.AssertNoErrorf(err, "test logger failed to write")
191+
}
192+
193+
func TestSecretOutputs(t *testing.T) {
194+
t.Run("nested secrets", func(t *testing.T) {
195+
ctx := context.Background()
196+
197+
tofu, err := NewTofu(ctx, nil)
198+
require.NoError(t, err, "error initializing tofu")
199+
var buffer bytes.Buffer
200+
logger := &testLogger{r: &buffer}
201+
202+
outputs := []TFOutputSpec{
203+
{Name: "nested_sensitive_output"},
204+
}
205+
ms := TFModuleSource(filepath.Join(getCwd(t), "testdata", "modules", "test_module"))
206+
inputs := map[string]any{
207+
"inputVar": "test",
208+
"anotherInputVar": resource.NewSecretProperty(&resource.Secret{Element: resource.NewStringProperty("somevalue")}),
209+
}
210+
emptyProviders := map[string]resource.PropertyMap{}
211+
err = CreateTFFile("test", ms, "", tofu.WorkingDir(),
212+
resource.NewPropertyMapFromMap(inputs), outputs, emptyProviders)
213+
require.NoError(t, err, "error creating tf file")
214+
215+
err = tofu.Init(ctx, logger)
216+
require.NoErrorf(t, err, "error running tofu init: %s", buffer.String())
217+
initialPlan, err := tofu.Plan(ctx, logger)
218+
require.NoErrorf(t, err, "error running tofu plan (before apply): %s", buffer.String())
219+
require.NotNil(t, initialPlan, "expected a non-nil plan")
220+
221+
plannedOutputs := initialPlan.Outputs()
222+
require.Equal(t, resource.PropertyMap{
223+
"nested_sensitive_output": resource.MakeComputed(resource.NewStringProperty("")),
224+
}, plannedOutputs)
225+
226+
state, err := tofu.Apply(ctx, logger)
227+
require.NoErrorf(t, err, "error running tofu apply: %s", buffer.String())
228+
moduleOutputs := state.Outputs()
229+
// output value is the same as the input
230+
require.Equal(t, resource.PropertyMap{
231+
resource.PropertyKey("nested_sensitive_output"): resource.MakeSecret(
232+
resource.NewObjectProperty(
233+
resource.NewPropertyMapFromMap(map[string]any{
234+
"A": resource.NewStringProperty("test"),
235+
"B": resource.NewStringProperty("somevalue"),
236+
"C": resource.NewStringProperty("test"),
237+
}))),
238+
}, moduleOutputs)
239+
})
240+
}

pkg/tfsandbox/testdata/modules/test_module/outputs.tf

+9
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,12 @@ output "statically_known" {
1414
output "number_output" {
1515
value = var.input_number_var
1616
}
17+
18+
output "nested_sensitive_output" {
19+
value = {
20+
A = var.input_var
21+
B = var.another_input_var
22+
# This will be unknown during plan
23+
C = terraform_data.example.output
24+
}
25+
}

pkg/tfsandbox/testdata/modules/test_module/variables.tf

+5
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ variable "input_number_var" {
99
// we can't pass in a big value
1010
default = 4222222222222222222
1111
}
12+
13+
variable "another_input_var" {
14+
type = string
15+
default = "default"
16+
}

pkg/tfsandbox/tf_file.go

+33-25
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ type TFOutputSpec struct {
1717
}
1818

1919
const (
20-
terraformDataResourceType = "terraform_data"
21-
terraformDataResourceName = "unknown_proxy"
22-
terraformDataResourcePrefix = "internal_output_"
20+
terraformDataResourceType = "terraform_data"
21+
terraformDataResourceName = "unknown_proxy"
22+
terraformIsSecretOutputPrefix = "internal_output_is_secret_"
2323
)
2424

2525
func writeTerraformFilesToDirectory() (string, bool) {
@@ -145,6 +145,7 @@ func CreateTFFile(
145145
containsUnknowns := inputs.ContainsUnknowns()
146146

147147
resources := map[string]map[string]interface{}{}
148+
mOutputs := map[string]map[string]interface{}{}
148149
providers := map[string]interface{}{}
149150

150151
// NOTE: this should only happen at plan time. At apply time all computed values
@@ -190,34 +191,41 @@ func CreateTFFile(
190191
moduleProps["providers"] = providersField
191192
}
192193

193-
// To expose outputs from a module, we create proxy resources of type "terraform_data"
194-
// the inputs to those resources are the outputs of the source module
195-
// the resources are named "internal_output_<output_name>" and have the following format
196-
// "resource": {
197-
// "terraform_data": {
198-
// "internal_output_output_name1": {
199-
// "input": "${module.source_module.output_name1}"
200-
// },
201-
// "internal_output_output_name2": {
202-
// "input": "${module.source_module.output_name2}"
203-
// },
204-
// ...
205-
// }
194+
// To expose outputs from a module, we need to account for the secretness of the outputs
195+
// If you try and output a secret value without setting `sensitive: true` in the output
196+
// The deployment will fail. We need to be able to handle this dynamically since we won't know
197+
// the secret status until after the operation.
198+
//
199+
// To handle this, we use two outputs for each output value:
200+
// - The first output is the actual value that we mark as non-sensitive to avoid the error
201+
// - The second output is a boolean that indicates whether the value is a secret or not.
202+
//
203+
// "output": {
204+
// "name1": {
205+
// "value": "${nonsensitive(module.source_module.output_name1)}"
206+
// },
207+
// "internal_output_is_secret_name1": {
208+
// "value": "${issensitive(module.source_module.output_name1)}"
209+
// },
210+
// ...
206211
// }
207-
// the reason we are using terraform_data resource is because
208-
// they maintain unknown-ness and secretness of the outputs of the modules
209-
// regardless of whether the outputs were marked as sensitive or not
212+
//
213+
// NOTE: terraform only allows plain booleans in the output.sensitive field.
214+
// i.e. `sensitive: "${issensitive(module.source_module.output_name1)}"` won't work
210215
for _, output := range outputs {
211-
if _, ok := resources[terraformDataResourceType]; !ok {
212-
resources[terraformDataResourceType] = map[string]interface{}{}
216+
mOutputs[output.Name] = map[string]any{
217+
// wrapping in jsondecode/jsonencode to workaround an issue where nonsensitive/issensitive is not recursive
218+
"value": fmt.Sprintf("${jsondecode(nonsensitive(jsonencode(module.%s.%s)))}", name, output.Name),
213219
}
214-
215-
resourceName := fmt.Sprintf("%s%s", terraformDataResourcePrefix, output.Name)
216-
resources[terraformDataResourceType][resourceName] = map[string]interface{}{
217-
"input": fmt.Sprintf("${module.%s.%s}", name, output.Name),
220+
mOutputs[fmt.Sprintf("%s%s", terraformIsSecretOutputPrefix, output.Name)] = map[string]any{
221+
"value": fmt.Sprintf("${jsondecode(issensitive(jsonencode(module.%s.%s)))}", name, output.Name),
218222
}
219223
}
220224

225+
if len(mOutputs) > 0 {
226+
tfFile["output"] = mOutputs
227+
}
228+
221229
if len(resources) > 0 {
222230
tfFile["resource"] = resources
223231
}

0 commit comments

Comments
 (0)