Add ephemeralasnull() function (#3154)

Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
Co-authored-by: Martin Atkins <mart@degeneration.co.uk>
Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
Christian Mesh
2025-08-22 07:10:30 -04:00
parent b5d414331f
commit 60b268200c
6 changed files with 233 additions and 1 deletions

View File

@@ -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{""},

View File

@@ -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
}
})
},
})

View File

@@ -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)
}
})
}
}

View File

@@ -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),

View File

@@ -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")`,

View File

@@ -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,
}
```