diff --git a/internal/lang/funcs/descriptions.go b/internal/lang/funcs/descriptions.go index 2494f4e2ca..1ffcae81e9 100644 --- a/internal/lang/funcs/descriptions.go +++ b/internal/lang/funcs/descriptions.go @@ -177,6 +177,10 @@ var DescriptionList = map[string]descriptionEntry{ Description: "`endswith` takes two values: a string to check and a suffix string. The function returns true if the first string ends with that exact suffix.", ParamDescription: []string{"", ""}, }, + "ephemeralasnull": { + Description: "`ephemeralasnull` replaces any ephemeral values in the given value with a null value of the corresponding type.", + ParamDescription: []string{""}, + }, "file": { Description: "`file` reads the contents of a file at the given path and returns them as a string.", ParamDescription: []string{""}, diff --git a/internal/lang/funcs/ephemeral.go b/internal/lang/funcs/ephemeral.go new file mode 100644 index 0000000000..5fe2e9b678 --- /dev/null +++ b/internal/lang/funcs/ephemeral.go @@ -0,0 +1,63 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package funcs + +import ( + "github.com/opentofu/opentofu/internal/lang/marks" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var EphemeralAsNullFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowUnknown: true, + AllowNull: true, + AllowMarked: true, + AllowDynamicType: true, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + // The result type is always the same as the argument type. + return args[0].Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + return cty.Transform(args[0], func(_ cty.Path, val cty.Value) (cty.Value, error) { + ty := val.Type() + + // Preserve non-ephemeral marks + nonEphemeralMarks := val.Marks() + delete(nonEphemeralMarks, marks.Ephemeral) + + switch { + case val.IsNull(): + return cty.NullVal(ty).WithMarks(nonEphemeralMarks), nil + case !val.IsKnown(): + // This mirrors the logic in IsSensitive() + // + // When a value is unknown its ephemerality is also not yet + // finalized, because authors can write expressions where the + // ephemerality of the result is decided based on some other + // value that isn't yet known itself. As a simple example: + // var.unknown_bool ? var.some_ephemeral_value : "b" + // + // Therefore we must report that we can't predict whether an + // unknown value will be ephemeral or not. For more information, + // refer to https://github.com/opentofu/opentofu/issues/2415 + + return cty.UnknownVal(val.Type()).WithMarks(nonEphemeralMarks), nil + case val.HasMark(marks.Ephemeral): + // This whole value is marked as ephemeral and should be null + return cty.NullVal(val.Type()).WithMarks(nonEphemeralMarks), nil + default: + // Not marked + return val, nil + } + }) + }, +}) diff --git a/internal/lang/funcs/ephemeral_test.go b/internal/lang/funcs/ephemeral_test.go new file mode 100644 index 0000000000..f21595da86 --- /dev/null +++ b/internal/lang/funcs/ephemeral_test.go @@ -0,0 +1,128 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package funcs + +import ( + "reflect" + "testing" + + "github.com/opentofu/opentofu/internal/lang/marks" + "github.com/zclconf/go-cty/cty" +) + +func TestEphemeralAsNullFunc(t *testing.T) { + // This is a bit overkill, but it is better to be explicit + cases := []struct { + Name string + Input cty.Value + Expected cty.Value + }{ + {"null", + cty.NullVal(cty.String), + cty.NullVal(cty.String)}, + {"null_sensitive", + cty.NullVal(cty.String).Mark(marks.Sensitive), + cty.NullVal(cty.String).Mark(marks.Sensitive)}, + {"null_ephemeral", + cty.NullVal(cty.String).Mark(marks.Ephemeral), + cty.NullVal(cty.String)}, + {"null_complex", + cty.NullVal(cty.String).Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.NullVal(cty.String).Mark(marks.Sensitive)}, + {"unknown", + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String)}, + {"unknown_sensitive", + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + cty.UnknownVal(cty.String).Mark(marks.Sensitive)}, + {"unknown_ephemeral", + cty.UnknownVal(cty.String).Mark(marks.Ephemeral), + cty.UnknownVal(cty.String)}, + {"unknown_complex", + cty.UnknownVal(cty.String).Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.UnknownVal(cty.String).Mark(marks.Sensitive)}, + {"primitive", + cty.StringVal("myprimitive"), + cty.StringVal("myprimitive")}, + {"primitive_sensitive", + cty.StringVal("mysensitive").Mark(marks.Sensitive), + cty.StringVal("mysensitive").Mark(marks.Sensitive)}, + {"primitive_ephemeral", + cty.StringVal("myephemeral").Mark(marks.Ephemeral), + cty.NullVal(cty.String)}, + {"list", + cty.ListVal([]cty.Value{cty.StringVal("val")}), + cty.ListVal([]cty.Value{cty.StringVal("val")})}, + {"list_sensitive", + cty.ListVal([]cty.Value{cty.StringVal("val")}).Mark(marks.Sensitive), + cty.ListVal([]cty.Value{cty.StringVal("val")}).Mark(marks.Sensitive)}, + {"list_ephemeral", + cty.ListVal([]cty.Value{cty.StringVal("val")}).Mark(marks.Ephemeral), + cty.NullVal(cty.List(cty.String))}, + {"list_complex", + cty.ListVal([]cty.Value{cty.StringVal("val")}).Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.NullVal(cty.List(cty.String)).Mark(marks.Sensitive)}, + {"listcontents_sensitive", + cty.ListVal([]cty.Value{cty.StringVal("val").Mark(marks.Sensitive)}), + cty.ListVal([]cty.Value{cty.StringVal("val").Mark(marks.Sensitive)})}, + {"listcontents_ephemeral", + cty.ListVal([]cty.Value{cty.StringVal("val").Mark(marks.Ephemeral)}), + cty.ListVal([]cty.Value{cty.NullVal(cty.String)})}, + {"listcontents_complex", + cty.ListVal([]cty.Value{cty.StringVal("val").Mark(marks.Ephemeral).Mark(marks.Sensitive)}), + cty.ListVal([]cty.Value{cty.NullVal(cty.String).Mark(marks.Sensitive)})}, + {"listcontents_multiple", + cty.ListVal([]cty.Value{cty.StringVal("val").Mark(marks.Ephemeral).Mark(marks.Sensitive), cty.StringVal("other")}), + cty.ListVal([]cty.Value{cty.NullVal(cty.String).Mark(marks.Sensitive), cty.StringVal("other")})}, + {"listempty", + cty.ListValEmpty(cty.String), + cty.ListValEmpty(cty.String)}, + {"listempty_sensitive", + cty.ListValEmpty(cty.String).Mark(marks.Sensitive), + cty.ListValEmpty(cty.String).Mark(marks.Sensitive)}, + {"listempty_ephemeral", + cty.ListValEmpty(cty.String).Mark(marks.Ephemeral), + cty.NullVal(cty.List(cty.String))}, + {"listempty_complex", + cty.ListValEmpty(cty.String).Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.NullVal(cty.List(cty.String)).Mark(marks.Sensitive)}, + {"map", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val")}), + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val")})}, + {"map_sensitive", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val")}).Mark(marks.Sensitive), + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val")}).Mark(marks.Sensitive)}, + {"map_ephemeral", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val")}).Mark(marks.Ephemeral), + cty.NullVal(cty.Map(cty.String))}, + {"map_complex", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val")}).Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.NullVal(cty.Map(cty.String)).Mark(marks.Sensitive)}, + {"mapcontents_sensitive", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val").Mark(marks.Sensitive)}), + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val").Mark(marks.Sensitive)})}, + {"mapcontents_ephemeral", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val").Mark(marks.Ephemeral)}), + cty.MapVal(map[string]cty.Value{"key": cty.NullVal(cty.String)})}, + {"mapcontents_complex", + cty.MapVal(map[string]cty.Value{"key": cty.StringVal("val").Mark(marks.Ephemeral).Mark(marks.Sensitive)}), + cty.MapVal(map[string]cty.Value{"key": cty.NullVal(cty.String).Mark(marks.Sensitive)})}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + out, err := EphemeralAsNullFunc.Call([]cty.Value{tc.Input}) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(tc.Expected, out) { + t.Fatalf("Expected %#v, Got %#v", tc.Expected, out) + } + }) + } + +} diff --git a/internal/lang/functions.go b/internal/lang/functions.go index 7ad69d0078..a7c6cbd22c 100644 --- a/internal/lang/functions.go +++ b/internal/lang/functions.go @@ -146,6 +146,7 @@ func makeBaseFunctionTable(baseDir string) map[string]function.Function { "distinct": stdlib.DistinctFunc, "element": stdlib.ElementFunc, "endswith": funcs.EndsWithFunc, + "ephemeralasnull": funcs.EphemeralAsNullFunc, "chunklist": stdlib.ChunklistFunc, "file": funcs.MakeFileFunc(baseDir, false), "fileexists": funcs.MakeFileExistsFunc(baseDir), diff --git a/internal/lang/functions_test.go b/internal/lang/functions_test.go index a4a5313cdb..11a11bcdad 100644 --- a/internal/lang/functions_test.go +++ b/internal/lang/functions_test.go @@ -376,7 +376,13 @@ func TestFunctions(t *testing.T) { cty.False, }, }, - + "ephemeralasnull": { + { + // We have more specific tests in the funcs package + `ephemeralasnull("foo")`, + cty.StringVal("foo"), + }, + }, "file": { { `file("hello.txt")`, diff --git a/website/docs/language/functions/ephemeralasnull.mdx b/website/docs/language/functions/ephemeralasnull.mdx new file mode 100644 index 0000000000..f4df0a91ce --- /dev/null +++ b/website/docs/language/functions/ephemeralasnull.mdx @@ -0,0 +1,30 @@ +--- +sidebar_label: ephemeralasnull +description: The ephemeral as null function replaces all ephemeral values within the given object with null. +--- + +# `ephemeralasnull` Function + +`ephemeralasnull` takes any value and returns a copy of it with any ephemeral values replaced with null. Ephemeral +values can come from variables and outputs marked as ephemeral, as well as ephemeral resources. + +## Examples + +```hcl +> ephemeralasnull("Hello World") +"Hello World" +> ephemeralasnull(var.ephemeral_variable) +null +> ephemeralasnull(ephemeral.some_resource) +null +> ephemeralasnull([4, var.ephemeral_variable]) +[4, null] +> ephemeralasnull({ + "field": "value", + "ephemeral": var.ephemeral_variable, +}) +{ + "field": "value", + "ephemeral": null, +} +```