Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changes/v1.12/ENHANCEMENTS-20250203-175807.yaml
Copy link
Member

Choose a reason for hiding this comment

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

What do you think of this comment? If we are releasing this as an experiment, we likely do not need to do that yet. However, it is to be released in the stable version, we may want to consider extending the constraints to module calls.

Copy link
Member Author

Choose a reason for hiding this comment

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

Excellent question! I brought this up in our team jam on Tuesday and @jbardin raised a concern with this approach: If a practitioner has a module with fully typed outputs and later adds an output without type, Terraform will suddenly evaluate the module differently. This may cause more confusion than it helps the practitioner. But I have no strong opinion on this, and would be happy to discuss this further with you both.

I assume this could be one place where we could make use of the type information

if d.Operation == walkValidate {
atys := make(map[string]cty.Type, len(outputConfigs))
for name := range outputConfigs {
atys[name] = cty.DynamicPseudoType // output values are dynamically-typed
}
instTy := cty.Object(atys)
switch {
case callConfig.Count != nil:
return cty.UnknownVal(cty.List(instTy)), diags
case callConfig.ForEach != nil:
return cty.UnknownVal(cty.Map(instTy)), diags
default:
return cty.UnknownVal(instTy), diags
}
}

Copy link
Member

Choose a reason for hiding this comment

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

Thank you. That concern probably makes sense, but still not entirely clear to me. I'd love to discuss further too

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: ENHANCEMENTS
body: 'config: `output` blocks now can have an explicit type constraints'
time: 2025-02-03T17:58:07.110141+01:00
custom:
Issue: "36411"
28 changes: 28 additions & 0 deletions internal/configs/named_values.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,23 @@ type Output struct {
Sensitive bool
Ephemeral 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

Preconditions []*CheckRule

DescriptionSet bool
SensitiveSet bool
EphemeralSet bool
// 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
Comment on lines +363 to +365
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to create a test showing the use of override files to override an output with type constraints? I can imagine how this bool would be useful in that case, but it looks like this field is set but unused currently.


DeclRange hcl.Range
}
Expand Down Expand Up @@ -390,6 +402,19 @@ 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
}
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 @@ -525,6 +550,9 @@ var outputBlockSchema = &hcl.BodySchema{
{
Name: "ephemeral",
},
{
Name: "type",
},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "precondition"},
Expand Down
11 changes: 11 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,11 @@
output "string" {
type = string
value = "Hello"
}

output "object" {
type = object({
name = optional(string, "Bart"),
})
value = {}
}
Comment on lines +6 to +11
Copy link
Member

Choose a reason for hiding this comment

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

Should we support both explicit and implicit default values?

This already introduces support for implicit defaults through optional attributes. While I initially thought against defaults entirely, they likely provide real value to users. For consistency sake,
Perhaps we should support both patterns rather than just one.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure if I understand what you're asking here. How would an explicit default value work in the context of an output block?

Copy link
Member

Choose a reason for hiding this comment

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

I was thinking we could assign the default when the assigned attribute is null,
like we have for variables https://developer.hashicorp.com/terraform/language/values/variables#default-values

Copy link
Member Author

Choose a reason for hiding this comment

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

Does this mean you're proposing to introduce a new default attribute for output blocks? I think we would have to discuss whether that makes sense from a language point of view.

Unlike variables, outputs usually get their values from configuration and don't depend on user-supplied values. Where a user might only supply some of the variables, but not all, we fall back on the default. An output should always have a value, even if it is sometimes null.

The optional modifier in the above test case only works within objects https://developer.hashicorp.com/terraform/language/expressions/type-constraints#optional-object-type-attributes

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I was proposing a new default attribute for outputs. Your explanation is very helpful, thank you.

An output should always have a value, even if it is sometimes null.

Make sense - since outputs are guaranteed to have a value (with null being a valid value), then there's no need for default handling.

When this is being documented, I think it would however make sense to add some documentation clarifying this distinction between variables and outputs, particularly around null handling? This might help other users avoid similar misconceptions.

53 changes: 50 additions & 3 deletions internal/terraform/context_apply2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2830,7 +2830,7 @@ func TestContext2Apply_destroy_and_forget(t *testing.T) {
resource "test_object" "a" {
test_string = "foo"
}

resource "test_object" "b" {
test_string = "foo"
}
Expand Down Expand Up @@ -2876,7 +2876,7 @@ func TestContext2Apply_destroy_and_forget(t *testing.T) {
}
resource "test_object" "a" {
for_each = local.items

test_string = each.value
}
`,
Expand Down Expand Up @@ -2982,7 +2982,7 @@ func TestContext2Apply_destroy_and_forget_single_resource(t *testing.T) {
"main.tf": `
removed {
from = test_object.a

lifecycle {
destroy = false
}
Expand Down Expand Up @@ -3766,3 +3766,50 @@ resource "test_object" "c" {
}
t.Fatal("failed to find destroy destroy dependency between test_object.a(destroy) and test_object.c(destroy)")
}

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, nil)
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("Bart"),
}),
"object_override": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("Lisa"),
}),
}
ovs := state.RootOutputValues
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)
}
}
}
36 changes: 35 additions & 1 deletion internal/terraform/node_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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 @@ -436,7 +438,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.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 @@ -529,6 +531,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, 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.
})
Comment on lines +556 to +559
Copy link
Member

Choose a reason for hiding this comment

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

Prior art here

refs, moreDiags := langrefs.ReferencesInExpr(addrs.ParseRef, ev.expr)
diags = diags.Append(moreDiags)
scope := ev.ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey)
if scope != nil {
ev.hclCtx, moreDiags = scope.EvalContext(refs)
} else {
// This shouldn't happen in real code, but it can unfortunately arise
// in unit tests due to incompletely-implemented mocks. :(
ev.hclCtx = &hcl.EvalContext{}
}

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 @@ -25,7 +25,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) {
ctx.ChecksState = checks.NewState(nil)
ctx.DeferralsState = deferring.NewDeferred(false)

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 @@ -55,7 +55,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 @@ -83,6 +83,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 @@ -105,7 +106,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 @@ -129,8 +130,9 @@ func TestNodeApplyableOutputExecute_sensitiveValueAndOutput(t *testing.T) {
ctx.DeferralsState = deferring.NewDeferred(false)

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,20 @@
output "string" {
type = string
value = true
}

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

output "object_override" {
type = object({
name = optional(string, "Bart")
})
value = {
name = "Lisa"
}
}
Loading