Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/configs/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,5 +232,18 @@ func checkModuleExperiments(m *Module) hcl.Diagnostics {
}
*/

if !m.ActiveExperiments.Has(experiments.OutputTypeConstraints) {
for _, oc := range m.Outputs {
if oc.TypeSet {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Output value type constraints are experimental",
Detail: "The \"type\" argument for output values is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding output_type_constraints to the list of active experiments.",
Subject: oc.TypeRange.Ptr(),
})
}
}
}

return diags
}
32 changes: 32 additions & 0 deletions internal/configs/named_values.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,21 @@ type Output struct {
DependsOn []hcl.Traversal
Sensitive bool

// ConstraintType is a type constraint which the result is guaranteed
// to conform to when used in the calling module.
ConstraintType cty.Type
// TypeDefaults describes any optional attribute defaults that should be
// applied to the Expr result before type conversion.
TypeDefaults *typeexpr.Defaults
// TypeSet is true if there was an explicit "type" argument in the
// configuration block. This is mainly to allow distinguish explicitly
// setting vs. just using the default type constraint when processing
// override files.
TypeSet bool
// TypeRange is the source range for the type constraint recorded in
// ConstraintType. This is valid only if TypeSet is true.
TypeRange hcl.Range

Preconditions []*CheckRule

DescriptionSet bool
Expand Down Expand Up @@ -438,6 +453,20 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic
o.Expr = attr.Expr
}

if attr, exists := content.Attributes["type"]; exists {
ty, defaults, moreDiags := typeexpr.TypeConstraintWithDefaults(attr.Expr)
diags = append(diags, moreDiags...)
o.ConstraintType = ty
o.TypeDefaults = defaults
o.TypeSet = true
o.TypeRange = attr.Expr.Range()
}
if o.ConstraintType == cty.NilType {
// If no constraint is given then the type will be inferred
// automatically from the value.
o.ConstraintType = cty.DynamicPseudoType
}

if attr, exists := content.Attributes["sensitive"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Sensitive)
diags = append(diags, valDiags...)
Expand Down Expand Up @@ -555,6 +584,9 @@ var outputBlockSchema = &hcl.BodySchema{
Name: "value",
Required: true,
},
{
Name: "type",
},
{
Name: "depends_on",
},
Expand Down
2 changes: 2 additions & 0 deletions internal/configs/parser_config_dir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestParserLoadConfigDirSuccess(t *testing.T) {
name := info.Name()
t.Run(name, func(t *testing.T) {
parser := NewParser(nil)
parser.AllowLanguageExperiments(true)
path := filepath.Join("testdata/valid-modules", name)

mod, diags := parser.LoadConfigDir(path)
Expand Down Expand Up @@ -91,6 +92,7 @@ func TestParserLoadConfigDirSuccess(t *testing.T) {
parser := testParser(map[string]string{
"mod/" + name: string(src),
})
parser.AllowLanguageExperiments(true)

_, diags := parser.LoadConfigDir("mod")
if diags.HasErrors() {
Expand Down
2 changes: 2 additions & 0 deletions internal/configs/parser_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ func TestParserLoadConfigFileSuccess(t *testing.T) {
parser := testParser(map[string]string{
name: string(src),
})
parser.AllowLanguageExperiments(true)

_, diags := parser.LoadConfigFile(name)
diags = filterExperimentEnabledDiagnostics(t, diags)
if len(diags) != 0 {
t.Errorf("unexpected diagnostics")
for _, diag := range diags {
Expand Down
23 changes: 23 additions & 0 deletions internal/configs/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/davecgh/go-spew/spew"
Expand Down Expand Up @@ -168,6 +169,28 @@ func assertExactDiagnostics(t *testing.T, diags hcl.Diagnostics, want []string)
return bad
}

// filterExperimentEnabledDiagnostics takes a set of diagnostics and returns
// a filtered set which removes any warning diagnostics that are reporting
// that an experimental feature is enabled.
//
// We sometimes use this as a pragmatic exception in tests that would otherwise
// fail if any warnings are returned, because it means we can graduate a
// feature from experimental to stable without having to reclassify its tests
// from warning tests to success tests.
func filterExperimentEnabledDiagnostics(t *testing.T, diags hcl.Diagnostics) hcl.Diagnostics {
ret := make(hcl.Diagnostics, 0, len(diags))
for _, diag := range diags {
if diag.Severity == hcl.DiagWarning {
summary := diag.Summary
if strings.HasPrefix(summary, `Experimental feature "`) && strings.HasSuffix(summary, `" is active`) {
continue
}
}
ret = append(ret, diag)
}
return ret
}

func assertResultDeepEqual(t *testing.T, got, want interface{}) bool {
t.Helper()
if !reflect.DeepEqual(got, want) {
Expand Down
15 changes: 15 additions & 0 deletions internal/configs/testdata/valid-files/output-type-constraint.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
terraform {
experiments = [output_type_constraints]
}

output "string" {
type = string
value = "Hello"
}

output "object" {
type = object({
name = optional(string, "Ermintrude"),
})
value = {}
}
2 changes: 2 additions & 0 deletions internal/experiments/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
SuppressProviderSensitiveAttrs = Experiment("provider_sensitive_attrs")
ConfigDrivenMove = Experiment("config_driven_move")
PreconditionsPostconditions = Experiment("preconditions_postconditions")
OutputTypeConstraints = Experiment("output_type_constraints")
)

func init() {
Expand All @@ -28,6 +29,7 @@ func init() {
registerConcludedExperiment(ConfigDrivenMove, "Declarations of moved resource instances using \"moved\" blocks can now be used by default, without enabling an experiment.")
registerConcludedExperiment(PreconditionsPostconditions, "Condition blocks can now be used by default, without enabling an experiment.")
registerConcludedExperiment(ModuleVariableOptionalAttrs, "The final feature corresponding to this experiment differs from the experimental form and is available in the Terraform language from Terraform v1.3.0 onwards.")
registerCurrentExperiment(OutputTypeConstraints)
}

// GetCurrent takes an experiment name and returns the experiment value
Expand Down
47 changes: 47 additions & 0 deletions internal/terraform/context_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6562,6 +6562,53 @@ func TestContext2Apply_outputBasic(t *testing.T) {
}
}

func TestContext2Apply_outputWithTypeContraint(t *testing.T) {
m := testModule(t, "apply-output-type-constraint")
p := testProvider("aws")
p.PlanResourceChangeFn = testDiffFn
p.ApplyResourceChangeFn = testApplyFn
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})

plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts)
assertNoErrors(t, diags)

state, diags := ctx.Apply(plan, m)
if diags.HasErrors() {
t.Fatalf("diags: %s", diags.Err())
}

wantValues := map[string]cty.Value{
"string": cty.StringVal("true"),
"object_default": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("Ermintrude"),
}),
"object_override": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("Peppa"),
}),
}
ovs := state.RootModule().OutputValues
for name, want := range wantValues {
os, ok := ovs[name]
if !ok {
t.Errorf("missing output value %q", name)
continue
}
if got := os.Value; !want.RawEquals(got) {
t.Errorf("wrong value for output %q\ngot: %#v\nwant: %#v", name, got, want)
}
}

for gotName := range ovs {
if _, ok := wantValues[gotName]; !ok {
t.Errorf("unexpected extra output value %q", gotName)
}
}
}

func TestContext2Apply_outputAdd(t *testing.T) {
m1 := testModule(t, "apply-output-add-before")
p1 := testProvider("aws")
Expand Down
36 changes: 35 additions & 1 deletion internal/terraform/node_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"log"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
Expand Down Expand Up @@ -341,7 +343,7 @@ func (n *NodeApplyableOutput) Execute(ctx EvalContext, op walkOperation) (diags
// This has to run before we have a state lock, since evaluation also
// reads the state
var evalDiags tfdiags.Diagnostics
val, evalDiags = ctx.EvaluateExpr(n.Config.Expr, cty.DynamicPseudoType, nil)
val, evalDiags = evalOutputValue(ctx, n.Addr, n.Config.Expr, n.Config.ConstraintType, n.Config.TypeDefaults)
diags = diags.Append(evalDiags)

// We'll handle errors below, after we have loaded the module.
Expand Down Expand Up @@ -406,6 +408,38 @@ If you do intend to export this data, annotate the output value as sensitive by
return diags
}

// evalOutputValue encapsulates the logic for transforming an author's value
// expression into a valid value of their declared type constraint, or returning
// an error describing why that isn't possible.
func evalOutputValue(ctx EvalContext, addr addrs.AbsOutputValue, expr hcl.Expression, wantType cty.Type, defaults *typeexpr.Defaults) (cty.Value, tfdiags.Diagnostics) {
// We can't pass wantType to EvaluateExpr here because we'll need to
// possibly apply our defaults before attempting type conversion below.
val, diags := ctx.EvaluateExpr(expr, cty.DynamicPseudoType, nil)
if diags.HasErrors() {
return cty.UnknownVal(wantType), diags
}

if defaults != nil {
val = defaults.Apply(val)
}

val, err := convert.Convert(val, wantType)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid output value",
Detail: fmt.Sprintf("The value expression does not match this output value's type constraint: %s.", tfdiags.FormatError(err)),
Subject: expr.Range().Ptr(),
// TODO: Populate EvalContext and Expression, but we can't do that
// as long as we're using the ctx.EvaluateExpr helper above because
// the EvalContext is hidden from us in that case.
})
return cty.UnknownVal(wantType), diags
}

return val, diags
}

// dag.GraphNodeDotter impl.
func (n *NodeApplyableOutput) DotNode(name string, opts *dag.DotOpts) *dag.DotNode {
return &dag.DotNode{
Expand Down
12 changes: 7 additions & 5 deletions internal/terraform/node_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) {
ctx.RefreshStateState = states.NewState().SyncWrapper()
ctx.ChecksState = checks.NewState(nil)

config := &configs.Output{Name: "map-output"}
config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType}
addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance)
node := &NodeApplyableOutput{Config: config, Addr: addr}
val := cty.MapVal(map[string]cty.Value{
Expand Down Expand Up @@ -50,7 +50,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) {
func TestNodeApplyableOutputExecute_noState(t *testing.T) {
ctx := new(MockEvalContext)

config := &configs.Output{Name: "map-output"}
config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType}
addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance)
node := &NodeApplyableOutput{Config: config, Addr: addr}
val := cty.MapVal(map[string]cty.Value{
Expand Down Expand Up @@ -78,6 +78,7 @@ func TestNodeApplyableOutputExecute_invalidDependsOn(t *testing.T) {
hcl.TraverseAttr{Name: "bar"},
},
},
ConstraintType: cty.DynamicPseudoType,
}
addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance)
node := &NodeApplyableOutput{Config: config, Addr: addr}
Expand All @@ -100,7 +101,7 @@ func TestNodeApplyableOutputExecute_sensitiveValueNotOutput(t *testing.T) {
ctx.StateState = states.NewState().SyncWrapper()
ctx.ChecksState = checks.NewState(nil)

config := &configs.Output{Name: "map-output"}
config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType}
addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance)
node := &NodeApplyableOutput{Config: config, Addr: addr}
val := cty.MapVal(map[string]cty.Value{
Expand All @@ -123,8 +124,9 @@ func TestNodeApplyableOutputExecute_sensitiveValueAndOutput(t *testing.T) {
ctx.ChecksState = checks.NewState(nil)

config := &configs.Output{
Name: "map-output",
Sensitive: true,
Name: "map-output",
Sensitive: true,
ConstraintType: cty.DynamicPseudoType,
}
addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance)
node := &NodeApplyableOutput{Config: config, Addr: addr}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
terraform {
experiments = [output_type_constraints]
}

output "string" {
type = string
value = true
}

output "object_default" {
type = object({
name = optional(string, "Ermintrude")
})
value = {}
}

output "object_override" {
type = object({
name = optional(string, "Ermintrude")
})
value = {
name = "Peppa"
}
}