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

Allow can and try functions to return known results with unknown values in the arguments #622

Merged
merged 1 commit into from
Aug 25, 2023
Merged
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
73 changes: 32 additions & 41 deletions ext/tryfunc/tryfunc.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,35 @@ func try(args []cty.Value) (cty.Value, error) {
var diags hcl.Diagnostics
for _, arg := range args {
closure := customdecode.ExpressionClosureFromVal(arg)
if dependsOnUnknowns(closure.Expression, closure.EvalContext) {
// We can't safely decide if this expression will succeed yet,
// and so our entire result must be unknown until we have
// more information.
return cty.DynamicVal, nil
}

v, moreDiags := closure.Value()
diags = append(diags, moreDiags...)

if moreDiags.HasErrors() {
continue // try the next one, if there is one to try
// If there's an error we know it will always fail and can
// continue. A more refined value will not remove an error from
// the expression.
continue
}

if !v.IsWhollyKnown() {
// If there are any unknowns in the value at all, we cannot be
// certain that the final value will be consistent or have the same
// type, so wee need to be conservative and return a dynamic value.

// There are two different classes of failure that can happen when
// an expression transitions from unknown to known; an operation on
// a dynamic value becomes invalid for the type once the type is
// known, or an index expression on a collection fails once the
// collection value is known. These changes from a
// valid-partially-unknown expression to an invalid-known
// expression can produce inconsistent results by changing which
// "try" argument is returned, which may be a collection with
// different previously known values, or a different type entirely
// ("try" does not require consistent argument types)
return cty.DynamicVal, nil
}

return v, nil // ignore any accumulated diagnostics if one succeeds
}

Expand Down Expand Up @@ -111,43 +128,17 @@ func try(args []cty.Value) (cty.Value, error) {

func can(arg cty.Value) (cty.Value, error) {
closure := customdecode.ExpressionClosureFromVal(arg)
if dependsOnUnknowns(closure.Expression, closure.EvalContext) {
// Can't decide yet, then.
return cty.UnknownVal(cty.Bool), nil
}

_, diags := closure.Value()
v, diags := closure.Value()
if diags.HasErrors() {
return cty.False, nil
}
return cty.True, nil
}

// dependsOnUnknowns returns true if any of the variables that the given
// expression might access are unknown values or contain unknown values.
//
// This is a conservative result that prefers to return true if there's any
// chance that the expression might derive from an unknown value during its
// evaluation; it is likely to produce false-positives for more complex
// expressions involving deep data structures.
func dependsOnUnknowns(expr hcl.Expression, ctx *hcl.EvalContext) bool {
for _, traversal := range expr.Variables() {
val, diags := traversal.TraverseAbs(ctx)
if diags.HasErrors() {
// If the traversal returned a definitive error then it must
// not traverse through any unknowns.
continue
}
if !val.IsWhollyKnown() {
// The value will be unknown if either it refers directly to
// an unknown value or if the traversal moves through an unknown
// collection. We're using IsWhollyKnown, so this also catches
// situations where the traversal refers to a compound data
// structure that contains any unknown values. That's important,
// because during evaluation the expression might evaluate more
// deeply into this structure and encounter the unknowns.
return true
}
if !v.IsWhollyKnown() {
// If the value is not wholly known, we still cannot be certain that
// the expression was valid. There may be yet index expressions which
// will fail once values are completely known.
return cty.UnknownVal(cty.Bool), nil
}
return false

return cty.True, nil
}
30 changes: 30 additions & 0 deletions ext/tryfunc/tryfunc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,36 @@ func TestTryFunc(t *testing.T) {
cty.StringVal("list").Mark("secret"),
``,
},
"nested known expression from unknown": {
// this expression contains an unknown, but will always return in
// "bar"
`try({u: false ? unknown : "bar"}, other)`,
map[string]cty.Value{
"unknown": cty.UnknownVal(cty.String),
"other": cty.MapVal(map[string]cty.Value{
"v": cty.StringVal("oops"),
}),
},
cty.ObjectVal(map[string]cty.Value{
"u": cty.StringVal("bar"),
}),
``,
},
"nested index op on unknown": {
// unknown and other have identical types, but we must return a
// dynamic value since v could change within the final result value
// after the first argument becomes known.
`try({u: unknown["foo"], v: "orig"}, other)`,
map[string]cty.Value{
"unknown": cty.UnknownVal(cty.Map(cty.String)),
"other": cty.MapVal(map[string]cty.Value{
"u": cty.StringVal("oops"),
"v": cty.StringVal("oops"),
}),
},
cty.DynamicVal,
``,
},
"three arguments, all fail": {
`try(this, that, this_thing_in_particular)`,
nil,
Expand Down