diff --git a/internal/lang/ephemeral/marshal.go b/internal/lang/ephemeral/marshal.go new file mode 100644 index 000000000000..28f9cb8bad37 --- /dev/null +++ b/internal/lang/ephemeral/marshal.go @@ -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 +} diff --git a/internal/lang/ephemeral/marshal_test.go b/internal/lang/ephemeral/marshal_test.go new file mode 100644 index 000000000000..f61e38d11056 --- /dev/null +++ b/internal/lang/ephemeral/marshal_test.go @@ -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) + } + }) + } +} diff --git a/internal/lang/funcs/conversion.go b/internal/lang/funcs/conversion.go index 25609ff02451..7b42bcd7224a 100644 --- a/internal/lang/funcs/conversion.go +++ b/internal/lang/funcs/conversion.go @@ -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" @@ -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 }, }) diff --git a/internal/plans/objchange/plan_valid.go b/internal/plans/objchange/plan_valid.go index f03666a87c49..721fca77007e 100644 --- a/internal/plans/objchange/plan_valid.go +++ b/internal/plans/objchange/plan_valid.go @@ -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 diff --git a/internal/plans/objchange/plan_valid_test.go b/internal/plans/objchange/plan_valid_test.go index 4445f29bb6c2..dfa833a3f471 100644 --- a/internal/plans/objchange/plan_valid_test.go +++ b/internal/plans/objchange/plan_valid_test.go @@ -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" ) @@ -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 { diff --git a/internal/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index 08c4b41ea3b0..1f350c9531ea 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -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) diff --git a/internal/states/state_deepcopy.go b/internal/states/state_deepcopy.go index 8486e9548565..f99828fea3b6 100644 --- a/internal/states/state_deepcopy.go +++ b/internal/states/state_deepcopy.go @@ -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 @@ -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, diff --git a/internal/terraform/context_apply_ephemeral_test.go b/internal/terraform/context_apply_ephemeral_test.go index d23d648c6012..6ae37bc03029 100644 --- a/internal/terraform/context_apply_ephemeral_test.go +++ b/internal/terraform/context_apply_ephemeral_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" + "github.com/hashicorp/terraform/internal/states" "github.com/zclconf/go-cty/cty" ) @@ -359,3 +360,372 @@ resource "test_object" "test" { _, diags = ctx.Apply(plan, m, nil) assertNoDiagnostics(t, diags) } + +func TestContext2Apply_write_only_attribute_not_in_plan_and_state(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +variable "ephem" { + type = string + ephemeral = true +} + +resource "ephem_write_only" "wo" { + normal = "normal" + write_only = var.ephem +} +`, + }) + + ephem := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "ephem_write_only": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "normal": { + Type: cty.String, + Required: true, + }, + "write_only": { + Type: cty.String, + WriteOnly: true, + Required: true, + }, + }, + }, + }, + }, + }, + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("ephem"): testProviderFuncFixed(ephem), + }, + }) + + ephemVar := &InputValue{ + Value: cty.StringVal("ephemeral_value"), + SourceType: ValueFromCLIArg, + } + plan, diags := ctx.Plan(m, nil, &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "ephem": ephemVar, + }, + }) + assertNoDiagnostics(t, diags) + + if len(plan.Changes.Resources) != 1 { + t.Fatalf("Expected 1 resource change, got %d", len(plan.Changes.Resources)) + } + + schemas, schemaDiags := ctx.Schemas(m, plan.PriorState) + assertNoDiagnostics(t, schemaDiags) + planChanges, err := plan.Changes.Decode(schemas) + if err != nil { + t.Fatalf("Failed to decode plan changes: %v.", err) + } + + if !planChanges.Resources[0].After.GetAttr("write_only").IsNull() { + t.Fatalf("Expected write_only to be null, got %v", planChanges.Resources[0].After.GetAttr("write_only")) + } + + state, diags := ctx.Apply(plan, m, &ApplyOpts{ + SetVariables: InputValues{ + "ephem": ephemVar, + }, + }) + assertNoDiagnostics(t, diags) + + resource := state.Resource(addrs.AbsResource{ + Module: addrs.RootModuleInstance, + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "ephem_write_only", + Name: "wo", + }, + }) + + if resource == nil { + t.Fatalf("Resource not found") + } + + resourceInstance := resource.Instances[addrs.NoKey] + if resourceInstance == nil { + t.Fatalf("Resource instance not found") + } + + attrs, err := resourceInstance.Current.Decode(cty.Object(map[string]cty.Type{ + "normal": cty.String, + "write_only": cty.String, + })) + if err != nil { + t.Fatalf("Failed to decode attributes: %v", err) + } + + if attrs.Value.GetAttr("normal").AsString() != "normal" { + t.Fatalf("normal attribute not as expected") + } + + if !attrs.Value.GetAttr("write_only").IsNull() { + t.Fatalf("write_only attribute should be null") + } +} + +func TestContext2Apply_update_write_only_attribute_not_in_plan_and_state(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +variable "ephem" { + type = string + ephemeral = true +} + +resource "ephem_write_only" "wo" { + normal = "normal" + write_only = var.ephem +} +`, + }) + + ephem := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "ephem_write_only": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "normal": { + Type: cty.String, + Required: true, + }, + "write_only": { + Type: cty.String, + WriteOnly: true, + Required: true, + }, + }, + }, + }, + }, + }, + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("ephem"): testProviderFuncFixed(ephem), + }, + }) + + ephemVar := &InputValue{ + Value: cty.StringVal("ephemeral_value"), + SourceType: ValueFromCLIArg, + } + + priorState := states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + mustResourceInstanceAddr("ephem_write_only.wo"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustParseJson(map[string]interface{}{ + "normal": "outdated", + }), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("ephem"), + Module: addrs.RootModule, + }) + }) + + plan, diags := ctx.Plan(m, priorState, &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "ephem": ephemVar, + }, + }) + assertNoDiagnostics(t, diags) + + if len(plan.Changes.Resources) != 1 { + t.Fatalf("Expected 1 resource change, got %d", len(plan.Changes.Resources)) + } + + schemas, schemaDiags := ctx.Schemas(m, plan.PriorState) + assertNoDiagnostics(t, schemaDiags) + planChanges, err := plan.Changes.Decode(schemas) + if err != nil { + t.Fatalf("Failed to decode plan changes: %v.", err) + } + + if !planChanges.Resources[0].After.GetAttr("write_only").IsNull() { + t.Fatalf("Expected write_only to be null, got %v", planChanges.Resources[0].After.GetAttr("write_only")) + } + + state, diags := ctx.Apply(plan, m, &ApplyOpts{ + SetVariables: InputValues{ + "ephem": ephemVar, + }, + }) + assertNoDiagnostics(t, diags) + + resource := state.Resource(addrs.AbsResource{ + Module: addrs.RootModuleInstance, + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "ephem_write_only", + Name: "wo", + }, + }) + + if resource == nil { + t.Fatalf("Resource not found") + } + + resourceInstance := resource.Instances[addrs.NoKey] + if resourceInstance == nil { + t.Fatalf("Resource instance not found") + } + + attrs, err := resourceInstance.Current.Decode(cty.Object(map[string]cty.Type{ + "normal": cty.String, + "write_only": cty.String, + })) + if err != nil { + t.Fatalf("Failed to decode attributes: %v", err) + } + + if attrs.Value.GetAttr("normal").AsString() != "normal" { + t.Fatalf("normal attribute not as expected") + } + + if !attrs.Value.GetAttr("write_only").IsNull() { + t.Fatalf("write_only attribute should be null") + } +} + +func TestContext2Apply_normal_attributes_becomes_write_only_attribute(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +variable "ephem" { + type = string + ephemeral = true +} + +resource "ephem_write_only" "wo" { + normal = "normal" + write_only = var.ephem +} +`, + }) + + ephem := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "ephem_write_only": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "normal": { + Type: cty.String, + Required: true, + }, + "write_only": { + Type: cty.String, + WriteOnly: true, + Required: true, + }, + }, + }, + }, + }, + }, + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("ephem"): testProviderFuncFixed(ephem), + }, + }) + + ephemVar := &InputValue{ + Value: cty.StringVal("ephemeral_value"), + SourceType: ValueFromCLIArg, + } + + priorState := states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + mustResourceInstanceAddr("ephem_write_only.wo"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustParseJson(map[string]interface{}{ + "normal": "normal", + "write_only": "this was not ephemeral but now is", + }), + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("ephem"), + Module: addrs.RootModule, + }) + }) + + plan, diags := ctx.Plan(m, priorState, &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "ephem": ephemVar, + }, + }) + assertNoDiagnostics(t, diags) + + if len(plan.Changes.Resources) != 1 { + t.Fatalf("Expected 1 resource change, got %d", len(plan.Changes.Resources)) + } + + schemas, schemaDiags := ctx.Schemas(m, plan.PriorState) + assertNoDiagnostics(t, schemaDiags) + planChanges, err := plan.Changes.Decode(schemas) + if err != nil { + t.Fatalf("Failed to decode plan changes: %v.", err) + } + + if !planChanges.Resources[0].After.GetAttr("write_only").IsNull() { + t.Fatalf("Expected write_only to be null, got %v", planChanges.Resources[0].After.GetAttr("write_only")) + } + + state, diags := ctx.Apply(plan, m, &ApplyOpts{ + SetVariables: InputValues{ + "ephem": ephemVar, + }, + }) + assertNoDiagnostics(t, diags) + + resource := state.Resource(addrs.AbsResource{ + Module: addrs.RootModuleInstance, + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "ephem_write_only", + Name: "wo", + }, + }) + + if resource == nil { + t.Fatalf("Resource not found") + } + + resourceInstance := resource.Instances[addrs.NoKey] + if resourceInstance == nil { + t.Fatalf("Resource instance not found") + } + + attrs, err := resourceInstance.Current.Decode(cty.Object(map[string]cty.Type{ + "normal": cty.String, + "write_only": cty.String, + })) + if err != nil { + t.Fatalf("Failed to decode attributes: %v", err) + } + + if attrs.Value.GetAttr("normal").AsString() != "normal" { + t.Fatalf("normal attribute not as expected") + } + + if !attrs.Value.GetAttr("write_only").IsNull() { + t.Fatalf("write_only attribute should be null") + } +} diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 7b7c06b7fdbd..4038cff42766 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -349,7 +349,7 @@ The -target option is not for routine use, and is provided only for exceptional panic("nil plan but no errors") } - if plan != nil { + if plan != nil && plan.Changes != nil { relevantAttrs, rDiags := c.relevantResourceAttrsForPlan(config, plan) diags = diags.Append(rDiags) plan.RelevantAttributes = relevantAttrs diff --git a/internal/terraform/context_plan_ephemeral_test.go b/internal/terraform/context_plan_ephemeral_test.go index e84c93e38fc7..f6b99a1ae24d 100644 --- a/internal/terraform/context_plan_ephemeral_test.go +++ b/internal/terraform/context_plan_ephemeral_test.go @@ -21,6 +21,7 @@ import ( func TestContext2Plan_ephemeralValues(t *testing.T) { for name, tc := range map[string]struct { + toBeImplemented bool module map[string]string expectValidateDiagnostics func(m *configs.Config) tfdiags.Diagnostics expectPlanDiagnostics func(m *configs.Config) tfdiags.Diagnostics @@ -549,8 +550,95 @@ You can correct this by removing references to ephemeral values, or by carefully }) }, }, + + "write_only attribute": { + module: map[string]string{ + "main.tf": ` +ephemeral "ephem_resource" "data" { +} +resource "ephem_write_only" "test" { + write_only = ephemeral.ephem_resource.data.value +} +`, + }, + expectOpenEphemeralResourceCalled: true, + expectValidateEphemeralResourceConfigCalled: true, + expectCloseEphemeralResourceCalled: true, + }, + + "write_only with hardcoded value": { + toBeImplemented: true, + module: map[string]string{ + "main.tf": ` +variable "ephem" { + type = string + ephemeral = true +} + +resource "ephem_write_only" "producer" { + write_only = "this is not allowed" +} + +output "out" { + value = ephemeralasnull(var.ephem) +} +`, + }, + inputs: InputValues{ + "ephem": &InputValue{ + Value: cty.StringVal("ami"), + }, + }, + expectPlanDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Cannot use hard-coded value in write-only attribute", + Detail: `The attribute "write_only" of resource "ephem_write_only.producer" is write-only and cannot be used with a static value, please use an ephemeral variable or an ephemeral resource instead.`, + }) + }, + }, + + "write_only attribute references": { + toBeImplemented: true, + module: map[string]string{ + "main.tf": ` +variable "ephem" { + type = string + ephemeral = true +} + +resource "ephem_write_only" "producer" { + write_only = var.ephem # Valid usage +} + +resource "ephem_write_only" "consumer" { + write_only = ephem_write_only.producer.write_only # Invalid usage +} + +output "out" { + value = ephemeralasnull(var.ephem) +} +`, + }, + inputs: InputValues{ + "ephem": &InputValue{ + Value: cty.StringVal("ami"), + }, + }, + expectValidateDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Cannot reference write-only attribute", + Detail: `The attribute "write_only" of resource "ephem_write_only.producer" is write-only and cannot be referenced in this context.`, + }) + }, + }, } { t.Run(name, func(t *testing.T) { + if tc.toBeImplemented { + t.Skip("To be implemented") + } + m := testModuleInline(t, tc.module) ephem := &testing_provider.MockProvider{ @@ -582,6 +670,19 @@ You can correct this by removing references to ephemeral values, or by carefully }, }, }, + ResourceTypes: map[string]providers.Schema{ + "ephem_write_only": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only": { + Type: cty.String, + WriteOnly: true, + Optional: true, + }, + }, + }, + }, + }, Functions: map[string]providers.FunctionDecl{ "either": { Parameters: []providers.FunctionParam{ diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index a6dc801f4889..1cdf08334ef8 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -1038,9 +1038,12 @@ func (n *NodeAbstractResourceInstance) plan( // Add the marks back to the planned new value -- this must happen after // ignore changes have been processed. We add in the schema marks as well, // to ensure that provider defined private attributes are marked correctly - // here. + // here. We remove the ephemeral marks, the provider is expected to return null + // for write-only attributes (the only place where ephemeral values are allowed). + // This is verified in objchange.AssertPlanValid already. unmarkedPlannedNewVal := plannedNewVal - plannedNewVal = plannedNewVal.MarkWithPaths(unmarkedPaths) + _, nonEphemeralMarks := marks.PathsWithMark(unmarkedPaths, marks.Ephemeral) + plannedNewVal = plannedNewVal.MarkWithPaths(nonEphemeralMarks) if sensitivePaths := schema.SensitivePaths(plannedNewVal, nil); len(sensitivePaths) != 0 { plannedNewVal = marks.MarkPaths(plannedNewVal, marks.Sensitive, sensitivePaths) } @@ -1121,8 +1124,8 @@ func (n *NodeAbstractResourceInstance) plan( plannedNewVal = resp.PlannedState plannedPrivate = resp.PlannedPrivate - if len(unmarkedPaths) > 0 { - plannedNewVal = plannedNewVal.MarkWithPaths(unmarkedPaths) + if len(nonEphemeralMarks) > 0 { + plannedNewVal = plannedNewVal.MarkWithPaths(nonEphemeralMarks) } for _, err := range plannedNewVal.Type().TestConformance(schema.ImpliedType()) { diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index 9725f3437999..ab289f0a2efe 100644 --- a/internal/terraform/node_resource_validate.go +++ b/internal/terraform/node_resource_validate.go @@ -762,21 +762,24 @@ func validateDependsOn(ctx EvalContext, dependsOn []hcl.Traversal) (diags tfdiag // by calling [tfdiags.Diagnostics.InConfigBody] before returning them to // any caller that expects fully-resolved diagnostics. func validateResourceForbiddenEphemeralValues(ctx EvalContext, value cty.Value, schema *configschema.Block) (diags tfdiags.Diagnostics) { - // NOTE: We take a schema argument in anticipation of a future feature - // that might allow managed resources to declare certain attributes as - // being "write-only", which would create a little nested island where - // ephemeral values are permitted in return for providers accepting that - // those values will not be preserved between plan and apply or between - // sequential plan/apply rounds. But we aren't doing that yet, so we - // just ignore that argument for now. - for _, path := range ephemeral.EphemeralValuePaths(value) { - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "Invalid use of ephemeral value", - "Ephemeral values are not valid in resource arguments, because resource instances must persist between Terraform phases.", - path, - )) + attr := schema.AttributeByPath(path) + if attr == nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Could not find schema for attribute", + "This is most likely a bug in Terraform, please report it.", + path, + )) + } else if !attr.WriteOnly { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid use of ephemeral value", + "Ephemeral values are not valid in resource arguments, because resource instances must persist between Terraform phases.", + path, + )) + } + } return diags }