Skip to content

Commit

Permalink
Merge pull request #36031 from hashicorp/TF-18617
Browse files Browse the repository at this point in the history
ephemeral: support write-only attributes
  • Loading branch information
DanielMSchmidt authored Dec 12, 2024
2 parents 84c1bb5 + 3a962e8 commit ab2846c
Show file tree
Hide file tree
Showing 29 changed files with 2,506 additions and 1,334 deletions.
4 changes: 4 additions & 0 deletions docs/plugin-protocol/tfplugin5.8.proto
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ message ClientCapabilities {
// The deferral_allowed capability signals that the client is able to
// handle deferred responses from the provider.
bool deferral_allowed = 1;

// The write_only_attributes_allowed capability signals that the client
// is able to handle write_only attributes for managed resources.
bool write_only_attributes_allowed = 2;
}

message Function {
Expand Down
4 changes: 4 additions & 0 deletions docs/plugin-protocol/tfplugin6.8.proto
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ message ClientCapabilities {
// The deferral_allowed capability signals that the client is able to
// handle deferred responses from the provider.
bool deferral_allowed = 1;

// The write_only_attributes_allowed capability signals that the client
// is able to handle write_only attributes for managed resources.
bool write_only_attributes_allowed = 2;
}

// Deferred is a message that indicates that change is deferred for a reason.
Expand Down
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)
}
})
}
}
53 changes: 53 additions & 0 deletions internal/lang/ephemeral/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package ephemeral

import (
"fmt"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)

func ValidateWriteOnlyAttributes(newVal cty.Value, schema *configschema.Block, provider addrs.AbsProviderConfig, addr addrs.AbsResourceInstance) (diags tfdiags.Diagnostics) {
if writeOnlyPaths := NonNullWriteOnlyPaths(newVal, schema, nil); len(writeOnlyPaths) != 0 {
for _, p := range writeOnlyPaths {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Write-only attribute set",
fmt.Sprintf(
"Provider %q returned a value for the write-only attribute \"%s%s\". Write-only attributes cannot be read back from the provider. This is a bug in the provider, which should be reported in the provider's own issue tracker.",
provider.String(), addr.String(), tfdiags.FormatCtyPath(p),
),
))
}
}
return diags
}

// NonNullWriteOnlyPaths returns a list of paths to attributes that are write-only
// and non-null in the given value.
func NonNullWriteOnlyPaths(val cty.Value, schema *configschema.Block, p cty.Path) (paths []cty.Path) {
if schema == nil {
return paths
}

for name, attr := range schema.Attributes {
attrPath := append(p, cty.GetAttrStep{Name: name})
attrVal, _ := attrPath.Apply(val)
if attr.WriteOnly && !attrVal.IsNull() {
paths = append(paths, attrPath)
}
}

for name, blockS := range schema.BlockTypes {
blockPath := append(p, cty.GetAttrStep{Name: name})
x := NonNullWriteOnlyPaths(val, &blockS.Block, blockPath)
paths = append(paths, x...)
}

return paths
}
146 changes: 146 additions & 0 deletions internal/lang/ephemeral/validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package ephemeral

import (
"testing"

"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/zclconf/go-cty/cty"
)

func TestNonNullWriteOnlyPaths(t *testing.T) {
for name, tc := range map[string]struct {
val cty.Value
schema *configschema.Block

expectedPaths []cty.Path
}{
"no write-only attributes": {
val: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-abc123"),
}),
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {
Type: cty.String,
},
},
},
},

"write-only attribute with null value": {
val: cty.ObjectVal(map[string]cty.Value{
"id": cty.NullVal(cty.String),
}),
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
},
},
},

"write-only attribute with non-null value": {
val: cty.ObjectVal(map[string]cty.Value{
"valid": cty.NullVal(cty.String),
"id": cty.StringVal("i-abc123"),
}),
schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"valid": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
"id": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
},
},
expectedPaths: []cty.Path{cty.GetAttrPath("id")},
},

"write-only attributes in blocks": {
val: cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"valid-write-only": cty.NullVal(cty.String),
"valid": cty.StringVal("valid"),
"id": cty.StringVal("i-abc123"),
"bar": cty.ObjectVal(map[string]cty.Value{
"valid-write-only": cty.NullVal(cty.String),
"valid": cty.StringVal("valid"),
"id": cty.StringVal("i-abc123"),
}),
}),
}),
schema: &configschema.Block{
BlockTypes: map[string]*configschema.NestedBlock{
"foo": {
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"valid-write-only": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
"valid": {
Type: cty.String,
Optional: true,
},
"id": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
},
BlockTypes: map[string]*configschema.NestedBlock{
"bar": {
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"valid-write-only": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
"valid": {
Type: cty.String,
Optional: true,
},
"id": {
Type: cty.String,
Optional: true,
WriteOnly: true,
},
},
},
},
},
},
},
},
},
expectedPaths: []cty.Path{cty.GetAttrPath("foo").GetAttr("id"), cty.GetAttrPath("foo").GetAttr("bar").GetAttr("id")},
},
} {
t.Run(name, func(t *testing.T) {
paths := NonNullWriteOnlyPaths(tc.val, tc.schema, nil)

if len(paths) != len(tc.expectedPaths) {
t.Fatalf("expected %d write-only paths, got %d", len(tc.expectedPaths), len(paths))
}

for i, path := range paths {
if !path.Equals(tc.expectedPaths[i]) {
t.Fatalf("expected path %#v, got %#v", tc.expectedPaths[i], path)
}
}
})
}
}
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
Loading

0 comments on commit ab2846c

Please sign in to comment.