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

Merged
merged 12 commits into from
Dec 12, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading