Skip to content

Commit 8a22769

Browse files
ephemeral: support write-only attributes
Only write-only attributes are allowed to be set to ephemeral values. Ephemeral values are set to null in both the plan and state. This commit only lays the basis, there is further work needed to support this both for stacks and the terminal UI interfaces.
1 parent 30b9379 commit 8a22769

File tree

17 files changed

+271
-82
lines changed

17 files changed

+271
-82
lines changed

internal/command/jsonstate/state.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/hashicorp/terraform/internal/addrs"
1515
"github.com/hashicorp/terraform/internal/command/jsonchecks"
16+
"github.com/hashicorp/terraform/internal/lang/ephemeral"
1617
"github.com/hashicorp/terraform/internal/lang/marks"
1718
"github.com/hashicorp/terraform/internal/states"
1819
"github.com/hashicorp/terraform/internal/states/statefile"
@@ -116,15 +117,21 @@ type Resource struct {
116117
// resource, whose structure depends on the resource type schema.
117118
type AttributeValues map[string]json.RawMessage
118119

119-
func marshalAttributeValues(value cty.Value) (unmarkedVal cty.Value, marshalledVals AttributeValues, sensitivePaths []cty.Path, err error) {
120+
func marshalAttributeValues(value cty.Value) (unmarkedVal cty.Value, marshalledVals AttributeValues, sensitivePaths []cty.Path, ephemeralPaths []cty.Path, err error) {
121+
// Every ephemeral value at this point must be set through a write_only attribute, otherwise
122+
// validation would have failed. For this reason we can safely remove all ephemeral values from the value.
123+
_, pvms := value.UnmarkDeepWithPaths()
124+
ephemeralPaths, _ = marks.PathsWithMark(pvms, marks.Ephemeral)
125+
value = ephemeral.RemoveEphemeralValuesForMarshaling(value)
126+
120127
// unmark our value to show all values
121128
value, sensitivePaths, err = unmarkValueForMarshaling(value)
122129
if err != nil {
123-
return cty.NilVal, nil, nil, err
130+
return cty.NilVal, nil, nil, nil, err
124131
}
125132

126133
if value == cty.NilVal || value.IsNull() {
127-
return value, nil, nil, nil
134+
return value, nil, nil, nil, nil
128135
}
129136

130137
ret := make(AttributeValues)
@@ -135,7 +142,7 @@ func marshalAttributeValues(value cty.Value) (unmarkedVal cty.Value, marshalledV
135142
vJSON, _ := ctyjson.Marshal(v, v.Type())
136143
ret[k.AsString()] = json.RawMessage(vJSON)
137144
}
138-
return value, ret, sensitivePaths, nil
145+
return value, ret, sensitivePaths, ephemeralPaths, nil
139146
}
140147

141148
// newState() returns a minimally-initialized state
@@ -403,7 +410,7 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module
403410

404411
var value cty.Value
405412
var sensitivePaths []cty.Path
406-
value, current.AttributeValues, sensitivePaths, err = marshalAttributeValues(riObj.Value)
413+
value, current.AttributeValues, sensitivePaths, _, err = marshalAttributeValues(riObj.Value)
407414
if err != nil {
408415
return nil, fmt.Errorf("preparing attribute values for %s: %w", current.Address, err)
409416
}
@@ -455,7 +462,7 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module
455462

456463
var value cty.Value
457464
var sensitivePaths []cty.Path
458-
value, deposed.AttributeValues, sensitivePaths, err = marshalAttributeValues(riObj.Value)
465+
value, deposed.AttributeValues, sensitivePaths, _, err = marshalAttributeValues(riObj.Value)
459466
if err != nil {
460467
return nil, fmt.Errorf("preparing attribute values for %s: %w", current.Address, err)
461468
}

internal/command/jsonstate/state_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,30 +118,35 @@ func TestMarshalAttributeValues(t *testing.T) {
118118
Attr cty.Value
119119
Want AttributeValues
120120
WantSensitivePaths []cty.Path
121+
WantEphemeralPaths []cty.Path
121122
}{
122123
{
123124
cty.NilVal,
124125
nil,
125126
nil,
127+
nil,
126128
},
127129
{
128130
cty.NullVal(cty.String),
129131
nil,
130132
nil,
133+
nil,
131134
},
132135
{
133136
cty.ObjectVal(map[string]cty.Value{
134137
"foo": cty.StringVal("bar"),
135138
}),
136139
AttributeValues{"foo": json.RawMessage(`"bar"`)},
137140
nil,
141+
nil,
138142
},
139143
{
140144
cty.ObjectVal(map[string]cty.Value{
141145
"foo": cty.NullVal(cty.String),
142146
}),
143147
AttributeValues{"foo": json.RawMessage(`null`)},
144148
nil,
149+
nil,
145150
},
146151
{
147152
cty.ObjectVal(map[string]cty.Value{
@@ -158,6 +163,7 @@ func TestMarshalAttributeValues(t *testing.T) {
158163
"baz": json.RawMessage(`["goodnight","moon"]`),
159164
},
160165
nil,
166+
nil,
161167
},
162168
// Sensitive values
163169
{
@@ -177,12 +183,33 @@ func TestMarshalAttributeValues(t *testing.T) {
177183
[]cty.Path{
178184
cty.GetAttrPath("baz").IndexInt(1),
179185
},
186+
nil,
187+
},
188+
// Ephemeral values
189+
{
190+
cty.ObjectVal(map[string]cty.Value{
191+
"bar": cty.MapVal(map[string]cty.Value{
192+
"hello": cty.StringVal("world"),
193+
}),
194+
"baz": cty.ListVal([]cty.Value{
195+
cty.StringVal("goodnight"),
196+
cty.StringVal("moon").Mark(marks.Ephemeral),
197+
}),
198+
}),
199+
AttributeValues{
200+
"bar": json.RawMessage(`{"hello":"world"}`),
201+
"baz": json.RawMessage(`["goodnight",null]`),
202+
},
203+
nil,
204+
[]cty.Path{
205+
cty.GetAttrPath("baz").IndexInt(1),
206+
},
180207
},
181208
}
182209

183210
for _, test := range tests {
184211
t.Run(fmt.Sprintf("%#v", test.Attr), func(t *testing.T) {
185-
val, got, sensitivePaths, err := marshalAttributeValues(test.Attr)
212+
val, got, sensitivePaths, ephemeralPaths, err := marshalAttributeValues(test.Attr)
186213
if err != nil {
187214
t.Fatalf("unexpected error: %s", err)
188215
}
@@ -192,14 +219,17 @@ func TestMarshalAttributeValues(t *testing.T) {
192219
if !reflect.DeepEqual(sensitivePaths, test.WantSensitivePaths) {
193220
t.Errorf("wrong marks\ngot: %#v\nwant: %#v\n", sensitivePaths, test.WantSensitivePaths)
194221
}
222+
if !reflect.DeepEqual(ephemeralPaths, test.WantEphemeralPaths) {
223+
t.Errorf("wrong marks\ngot: %#v\nwant: %#v\n", ephemeralPaths, test.WantEphemeralPaths)
224+
}
195225
if _, marks := val.Unmark(); len(marks) != 0 {
196226
t.Errorf("returned value still has marks; should have been unmarked\n%#v", marks)
197227
}
198228
})
199229
}
200230

201231
t.Run("reject unsupported marks", func(t *testing.T) {
202-
_, _, _, err := marshalAttributeValues(cty.ObjectVal(map[string]cty.Value{
232+
_, _, _, _, err := marshalAttributeValues(cty.ObjectVal(map[string]cty.Value{
203233
"disallowed": cty.StringVal("a").Mark("unsupported"),
204234
}))
205235
if err == nil {

internal/lang/ephemeral/marshal.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package ephemeral
5+
6+
import (
7+
"github.com/hashicorp/terraform/internal/lang/marks"
8+
"github.com/zclconf/go-cty/cty"
9+
)
10+
11+
// RemoveEphemeralValuesForMarshaling takes a value that possibly contains ephemeral
12+
// values and returns an equal value without ephemeral values. If an attribute contains
13+
// an ephemeral value it will be set to null.
14+
func RemoveEphemeralValuesForMarshaling(value cty.Value) cty.Value {
15+
// We currently have no error case, so we can ignore the error
16+
val, _ := cty.Transform(value, func(p cty.Path, v cty.Value) (cty.Value, error) {
17+
_, givenMarks := v.Unmark()
18+
if _, isEphemeral := givenMarks[marks.Ephemeral]; isEphemeral {
19+
// We'll strip the ephemeral mark but retain any other marks
20+
// that might be present on the input.
21+
delete(givenMarks, marks.Ephemeral)
22+
if !v.IsKnown() {
23+
// If the source value is unknown then we must leave it
24+
// unknown because its final type might be more precise
25+
// than the associated type constraint and returning a
26+
// typed null could therefore over-promise on what the
27+
// final result type will be.
28+
// We're deliberately constructing a fresh unknown value
29+
// here, rather than returning the one we were given,
30+
// because we need to discard any refinements that the
31+
// unknown value might be carrying that definitely won't
32+
// be honored when we force the final result to be null.
33+
return cty.UnknownVal(v.Type()).WithMarks(givenMarks), nil
34+
}
35+
return cty.NullVal(v.Type()).WithMarks(givenMarks), nil
36+
}
37+
return v, nil
38+
})
39+
return val
40+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package ephemeral
5+
6+
import (
7+
"testing"
8+
9+
"github.com/hashicorp/terraform/internal/lang/marks"
10+
"github.com/zclconf/go-cty/cty"
11+
)
12+
13+
func TestEphemeral_removeEphemeralValuesForMarshaling(t *testing.T) {
14+
for name, tc := range map[string]struct {
15+
input cty.Value
16+
want cty.Value
17+
}{
18+
"empty case": {
19+
input: cty.NullVal(cty.DynamicPseudoType),
20+
want: cty.NullVal(cty.DynamicPseudoType),
21+
},
22+
"ephemeral marks case": {
23+
input: cty.ObjectVal(map[string]cty.Value{
24+
"ephemeral": cty.StringVal("ephemeral_value").Mark(marks.Ephemeral),
25+
"normal": cty.StringVal("normal_value"),
26+
}),
27+
want: cty.ObjectVal(map[string]cty.Value{
28+
"ephemeral": cty.NullVal(cty.String),
29+
"normal": cty.StringVal("normal_value"),
30+
}),
31+
},
32+
"sensitive marks case": {
33+
input: cty.ObjectVal(map[string]cty.Value{
34+
"sensitive": cty.StringVal("sensitive_value").Mark(marks.Sensitive),
35+
"normal": cty.StringVal("normal_value"),
36+
}),
37+
want: cty.ObjectVal(map[string]cty.Value{
38+
"sensitive": cty.StringVal("sensitive_value").Mark(marks.Sensitive),
39+
"normal": cty.StringVal("normal_value"),
40+
}),
41+
},
42+
} {
43+
t.Run(name, func(t *testing.T) {
44+
got := RemoveEphemeralValuesForMarshaling(tc.input)
45+
46+
if !got.RawEquals(tc.want) {
47+
t.Errorf("got %#v, want %#v", got, tc.want)
48+
}
49+
})
50+
}
51+
}

internal/lang/funcs/conversion.go

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package funcs
66
import (
77
"strconv"
88

9+
"github.com/hashicorp/terraform/internal/lang/ephemeral"
910
"github.com/hashicorp/terraform/internal/lang/marks"
1011
"github.com/hashicorp/terraform/internal/lang/types"
1112
"github.com/zclconf/go-cty/cty"
@@ -126,29 +127,7 @@ var EphemeralAsNullFunc = function.New(&function.Spec{
126127
return args[0].Type(), nil
127128
},
128129
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
129-
return cty.Transform(args[0], func(p cty.Path, v cty.Value) (cty.Value, error) {
130-
_, givenMarks := v.Unmark()
131-
if _, isEphemeral := givenMarks[marks.Ephemeral]; isEphemeral {
132-
// We'll strip the ephemeral mark but retain any other marks
133-
// that might be present on the input.
134-
delete(givenMarks, marks.Ephemeral)
135-
if !v.IsKnown() {
136-
// If the source value is unknown then we must leave it
137-
// unknown because its final type might be more precise
138-
// than the associated type constraint and returning a
139-
// typed null could therefore over-promise on what the
140-
// final result type will be.
141-
// We're deliberately constructing a fresh unknown value
142-
// here, rather than returning the one we were given,
143-
// because we need to discard any refinements that the
144-
// unknown value might be carrying that definitely won't
145-
// be honored when we force the final result to be null.
146-
return cty.UnknownVal(v.Type()).WithMarks(givenMarks), nil
147-
}
148-
return cty.NullVal(v.Type()).WithMarks(givenMarks), nil
149-
}
150-
return v, nil
151-
})
130+
return ephemeral.RemoveEphemeralValuesForMarshaling(args[0]), nil
152131
},
153132
})
154133

internal/plans/changes.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/zclconf/go-cty/cty"
1010

1111
"github.com/hashicorp/terraform/internal/addrs"
12+
"github.com/hashicorp/terraform/internal/lang/ephemeral"
1213
"github.com/hashicorp/terraform/internal/lang/marks"
1314
"github.com/hashicorp/terraform/internal/providers"
1415
"github.com/hashicorp/terraform/internal/schemarepo"
@@ -605,16 +606,22 @@ type Change struct {
605606
// to call the corresponding Encode method of that struct rather than working
606607
// directly with its embedded Change.
607608
func (c *Change) Encode(ty cty.Type) (*ChangeSrc, error) {
609+
// When we encode the change, we need to serialize the Before and After
610+
// values, but we want to set values with the ephemeral mark to null.
611+
before := ephemeral.RemoveEphemeralValuesForMarshaling(c.Before)
612+
after := ephemeral.RemoveEphemeralValuesForMarshaling(c.After)
613+
608614
// We can't serialize value marks directly so we'll need to extract the
609615
// sensitive marks and store them in a separate field.
610616
//
611617
// We don't accept any other marks here. The caller should have dealt
612618
// with those somehow and replaced them with unmarked placeholders before
613619
// writing the value into the state.
614-
unmarkedBefore, marksesBefore := c.Before.UnmarkDeepWithPaths()
615-
unmarkedAfter, marksesAfter := c.After.UnmarkDeepWithPaths()
620+
unmarkedBefore, marksesBefore := before.UnmarkDeepWithPaths()
621+
unmarkedAfter, marksesAfter := after.UnmarkDeepWithPaths()
616622
sensitiveAttrsBefore, unsupportedMarksesBefore := marks.PathsWithMark(marksesBefore, marks.Sensitive)
617623
sensitiveAttrsAfter, unsupportedMarksesAfter := marks.PathsWithMark(marksesAfter, marks.Sensitive)
624+
618625
if len(unsupportedMarksesBefore) != 0 {
619626
return nil, fmt.Errorf(
620627
"prior value %s: can't serialize value marked with %#v (this is a bug in Terraform)",
@@ -645,6 +652,8 @@ func (c *Change) Encode(ty cty.Type) (*ChangeSrc, error) {
645652
After: afterDV,
646653
BeforeSensitivePaths: sensitiveAttrsBefore,
647654
AfterSensitivePaths: sensitiveAttrsAfter,
655+
BeforeWriteOnlyPaths: ephemeral.EphemeralValuePaths(c.Before),
656+
AfterWriteOnlyPaths: ephemeral.EphemeralValuePaths(c.After),
648657
Importing: c.Importing.Encode(),
649658
GeneratedConfig: c.GeneratedConfig,
650659
}, nil

internal/plans/changes_src.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,10 @@ type ChangeSrc struct {
388388
// the serialized change.
389389
BeforeSensitivePaths, AfterSensitivePaths []cty.Path
390390

391+
// BeforeWriteOnlyPaths and AfterWriteOnlyPaths are paths for any values
392+
// in Before or After (respectively) that are considered to be write-only.
393+
BeforeWriteOnlyPaths, AfterWriteOnlyPaths []cty.Path
394+
391395
// Importing is present if the resource is being imported as part of this
392396
// change.
393397
//

internal/plans/objchange/compatible.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ func assertObjectCompatible(schema *configschema.Block, planned, actual cty.Valu
5151
}
5252

5353
for name, attrS := range schema.Attributes {
54+
// We need to ignore write_only attributes, they are not recorded in the plan
55+
// and are ephemeral, therefore allowed to differ between plan and apply.
56+
if attrS.WriteOnly {
57+
continue
58+
}
59+
5460
plannedV := planned.GetAttr(name)
5561
actualV := actual.GetAttr(name)
5662

internal/stacks/tfstackdata1/convert.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ func DecodeProtoResourceInstanceObject(protoObj *StateResourceInstanceObjectV1)
182182
paths = append(paths, path)
183183
}
184184
objSrc.AttrSensitivePaths = paths
185+
// TODO: Handle write only paths
185186

186187
if len(protoObj.Dependencies) != 0 {
187188
objSrc.Dependencies = make([]addrs.ConfigResource, len(protoObj.Dependencies))

0 commit comments

Comments
 (0)