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

ephemeral: support write-only attributes #36031

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
40 changes: 40 additions & 0 deletions internal/lang/ephemeral/marshal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package ephemeral

import (
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/zclconf/go-cty/cty"
)

// RemoveEphemeralValues takes a value that possibly contains ephemeral
// values and returns an equal value without ephemeral values. If an attribute contains
// an ephemeral value it will be set to null.
func RemoveEphemeralValues(value cty.Value) cty.Value {
// We currently have no error case, so we can ignore the error
val, _ := cty.Transform(value, func(p cty.Path, v cty.Value) (cty.Value, error) {
_, givenMarks := v.Unmark()
if _, isEphemeral := givenMarks[marks.Ephemeral]; isEphemeral {
// We'll strip the ephemeral mark but retain any other marks
// that might be present on the input.
delete(givenMarks, marks.Ephemeral)
if !v.IsKnown() {
// If the source value is unknown then we must leave it
// unknown because its final type might be more precise
// than the associated type constraint and returning a
// typed null could therefore over-promise on what the
// final result type will be.
// We're deliberately constructing a fresh unknown value
// here, rather than returning the one we were given,
// because we need to discard any refinements that the
// unknown value might be carrying that definitely won't
// be honored when we force the final result to be null.
return cty.UnknownVal(v.Type()).WithMarks(givenMarks), nil
}
return cty.NullVal(v.Type()).WithMarks(givenMarks), nil
}
return v, nil
})
return val
}
61 changes: 61 additions & 0 deletions internal/lang/ephemeral/marshal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package ephemeral

import (
"testing"

"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/zclconf/go-cty/cty"
)

func TestEphemeral_removeEphemeralValues(t *testing.T) {
for name, tc := range map[string]struct {
input cty.Value
want cty.Value
}{
"empty case": {
input: cty.NullVal(cty.DynamicPseudoType),
want: cty.NullVal(cty.DynamicPseudoType),
},
"ephemeral marks case": {
input: cty.ObjectVal(map[string]cty.Value{
"ephemeral": cty.StringVal("ephemeral_value").Mark(marks.Ephemeral),
"normal": cty.StringVal("normal_value"),
}),
want: cty.ObjectVal(map[string]cty.Value{
"ephemeral": cty.NullVal(cty.String),
"normal": cty.StringVal("normal_value"),
}),
},
"sensitive marks case": {
input: cty.ObjectVal(map[string]cty.Value{
"sensitive": cty.StringVal("sensitive_value").Mark(marks.Sensitive),
"normal": cty.StringVal("normal_value"),
}),
want: cty.ObjectVal(map[string]cty.Value{
"sensitive": cty.StringVal("sensitive_value").Mark(marks.Sensitive),
"normal": cty.StringVal("normal_value"),
}),
},
"sensitive and ephemeral marks case": {
input: cty.ObjectVal(map[string]cty.Value{
"sensitive_and_ephemeral": cty.StringVal("sensitive_and_ephemeral_value").Mark(marks.Sensitive).Mark(marks.Ephemeral),
"normal": cty.StringVal("normal_value"),
}),
want: cty.ObjectVal(map[string]cty.Value{
"sensitive_and_ephemeral": cty.NullVal(cty.String).Mark(marks.Sensitive),
"normal": cty.StringVal("normal_value"),
}),
},
} {
t.Run(name, func(t *testing.T) {
got := RemoveEphemeralValues(tc.input)

if !got.RawEquals(tc.want) {
t.Errorf("got %#v, want %#v", got, tc.want)
}
})
}
}
25 changes: 2 additions & 23 deletions internal/lang/funcs/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package funcs
import (
"strconv"

"github.com/hashicorp/terraform/internal/lang/ephemeral"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/lang/types"
"github.com/zclconf/go-cty/cty"
Expand Down Expand Up @@ -126,29 +127,7 @@ var EphemeralAsNullFunc = function.New(&function.Spec{
return args[0].Type(), nil
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.Transform(args[0], func(p cty.Path, v cty.Value) (cty.Value, error) {
_, givenMarks := v.Unmark()
if _, isEphemeral := givenMarks[marks.Ephemeral]; isEphemeral {
// We'll strip the ephemeral mark but retain any other marks
// that might be present on the input.
delete(givenMarks, marks.Ephemeral)
if !v.IsKnown() {
// If the source value is unknown then we must leave it
// unknown because its final type might be more precise
// than the associated type constraint and returning a
// typed null could therefore over-promise on what the
// final result type will be.
// We're deliberately constructing a fresh unknown value
// here, rather than returning the one we were given,
// because we need to discard any refinements that the
// unknown value might be carrying that definitely won't
// be honored when we force the final result to be null.
return cty.UnknownVal(v.Type()).WithMarks(givenMarks), nil
}
return cty.NullVal(v.Type()).WithMarks(givenMarks), nil
}
return v, nil
})
return ephemeral.RemoveEphemeralValues(args[0]), nil
},
})

Expand Down
10 changes: 10 additions & 0 deletions internal/plans/objchange/plan_valid.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ func assertPlannedValueValid(attrS *configschema.Attribute, priorV, configV, pla
return errs
}

if attrS.WriteOnly {
// The provider is not allowed to return non-null values for write-only attributes
if !plannedV.IsNull() {
errs = append(errs, path.NewErrorf("planned value for write-only attribute is not null"))
}

// We don't want to evaluate further if the attribute is write-only and null
return errs
}

switch {
// The provider can plan any value for a computed-only attribute. There may
// be a config value here in the case where a user used `ignore_changes` on
Expand Down
22 changes: 22 additions & 0 deletions internal/plans/objchange/plan_valid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/tfdiags"
)

Expand Down Expand Up @@ -1965,6 +1966,27 @@ func TestAssertPlanValid(t *testing.T) {
}),
[]string{},
},

"write-only attributes": {
&configschema.Block{
Attributes: map[string]*configschema.Attribute{
"foo": {
Type: cty.String,
WriteOnly: true,
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.NullVal(cty.String),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("write-only").Mark(marks.Ephemeral),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.NullVal(cty.String),
}),
[]string{},
},
}

for name, test := range tests {
Expand Down
5 changes: 5 additions & 0 deletions internal/providers/testing/provider_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,11 @@ func (p *MockProvider) PlanResourceChange(r providers.PlanResourceChangeRequest)
return v, nil
}

// Write-only attributes always return null
if attrSchema.WriteOnly {
return cty.NullVal(v.Type()), nil
}

// get the current configuration value, to detect when a
// computed+optional attributes has become unset
configVal, err := path.Apply(r.Config)
Expand Down
8 changes: 4 additions & 4 deletions internal/states/state_deepcopy.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ func (os *ResourceInstanceObjectSrc) DeepCopy() *ResourceInstanceObjectSrc {
copy(attrsJSON, os.AttrsJSON)
}

var attrPaths []cty.Path
var sensitiveAttrPaths []cty.Path
if os.AttrSensitivePaths != nil {
attrPaths = make([]cty.Path, len(os.AttrSensitivePaths))
copy(attrPaths, os.AttrSensitivePaths)
sensitiveAttrPaths = make([]cty.Path, len(os.AttrSensitivePaths))
copy(sensitiveAttrPaths, os.AttrSensitivePaths)
}

var private []byte
Expand All @@ -168,7 +168,7 @@ func (os *ResourceInstanceObjectSrc) DeepCopy() *ResourceInstanceObjectSrc {
Private: private,
AttrsFlat: attrsFlat,
AttrsJSON: attrsJSON,
AttrSensitivePaths: attrPaths,
AttrSensitivePaths: sensitiveAttrPaths,
Dependencies: dependencies,
CreateBeforeDestroy: os.CreateBeforeDestroy,
decodeValueCache: os.decodeValueCache,
Expand Down
Loading
Loading