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

Improve dynamic attribute internal validation handling #1090

Merged
merged 9 commits into from
Feb 13, 2025
5 changes: 5 additions & 0 deletions .changes/unreleased/BUG FIXES-20250213-164700.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'internal/fwserver: Fixed bug where dynamic attributes would not prompt invalid configuration error messages'
time: 2025-02-13T16:47:00.4821-05:00
custom:
Issue: "1090"
59 changes: 28 additions & 31 deletions internal/fwserver/attribute_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,30 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt
return
}

configHasNullValue := attributeConfig.IsNull()
configHasUnknownValue := attributeConfig.IsUnknown()
// If the value is dynamic, we still need to check if the underlying value is null or unknown
if dynamicValuable, isDynamic := attributeConfig.(basetypes.DynamicValuable); !configHasNullValue && !configHasUnknownValue && isDynamic {
dynamicConfigVal, diags := dynamicValuable.ToDynamicValue(ctx)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
if dynamicConfigVal.IsUnderlyingValueNull() {
configHasNullValue = true
}

if dynamicConfigVal.IsUnderlyingValueUnknown() {
configHasUnknownValue = true
}
}

// Terraform CLI does not automatically perform certain configuration
// checks yet. If it eventually does, this logic should remain at least
// until Terraform CLI versions 0.12 through the release containing the
// checks are considered end-of-life.
// Reference: https://github.com/hashicorp/terraform/issues/30669
if a.IsComputed() && !a.IsOptional() && !attributeConfig.IsNull() {
if a.IsComputed() && !a.IsOptional() && !configHasNullValue {
resp.Diagnostics.AddAttributeError(
req.AttributePath,
"Invalid Configuration for Read-Only Attribute",
Expand All @@ -120,7 +138,7 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt
// until Terraform CLI versions 0.12 through the release containing the
// checks are considered end-of-life.
// Reference: https://github.com/hashicorp/terraform/issues/30669
if a.IsRequired() && attributeConfig.IsNull() {
if a.IsRequired() && configHasNullValue {
resp.Diagnostics.AddAttributeError(
req.AttributePath,
"Missing Configuration for Required Attribute",
Expand All @@ -134,14 +152,13 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt
//
// Write-only attributes can only be successfully used with a supporting client, so the only option for a practitoner to utilize a write-only attribute
// is to upgrade their Terraform CLI version to v1.11.0 or later.
if !req.ClientCapabilities.WriteOnlyAttributesAllowed && a.IsWriteOnly() && !attributeConfig.IsNull() {
if !req.ClientCapabilities.WriteOnlyAttributesAllowed && a.IsWriteOnly() && !configHasNullValue {
resp.Diagnostics.AddAttributeError(
req.AttributePath,
"WriteOnly Attribute Not Allowed",
fmt.Sprintf("The resource contains a non-null value for WriteOnly attribute %s. Write-only attributes are only supported in Terraform 1.11 and later.", req.AttributePath.String()),
)
}

req.AttributeConfig = attributeConfig

switch attributeWithValidators := a.(type) {
Expand Down Expand Up @@ -174,33 +191,13 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt
AttributeValidateNestedAttributes(ctx, a, req, resp)

// Show deprecation warnings only for known values.
if a.GetDeprecationMessage() != "" && !attributeConfig.IsNull() && !attributeConfig.IsUnknown() {
// Dynamic values need to perform more logic to check the config value for null/unknown-ness
dynamicValuable, ok := attributeConfig.(basetypes.DynamicValuable)
if !ok {
resp.Diagnostics.AddAttributeWarning(
req.AttributePath,
"Attribute Deprecated",
a.GetDeprecationMessage(),
)
return
}

dynamicConfigVal, diags := dynamicValuable.ToDynamicValue(ctx)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}

// For dynamic values, it's possible to be known when only the type is known.
// The underlying value can still be null or unknown, so check for that here
if !dynamicConfigVal.IsUnderlyingValueNull() && !dynamicConfigVal.IsUnderlyingValueUnknown() {
resp.Diagnostics.AddAttributeWarning(
req.AttributePath,
"Attribute Deprecated",
a.GetDeprecationMessage(),
)
}
if a.GetDeprecationMessage() != "" && !configHasNullValue && !configHasUnknownValue {
resp.Diagnostics.AddAttributeWarning(
req.AttributePath,
"Attribute Deprecated",
a.GetDeprecationMessage(),
)
return
}
}

Expand Down
91 changes: 91 additions & 0 deletions internal/fwserver/attribute_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,29 @@ func TestAttributeValidate(t *testing.T) {
},
},
},
"config-computed-dynamic-underlying-null-value": {
req: ValidateAttributeRequest{
AttributePath: path.Root("test"),
Config: tfsdk.Config{
Raw: tftypes.NewValue(tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"test": tftypes.DynamicPseudoType,
},
}, map[string]tftypes.Value{
"test": tftypes.NewValue(tftypes.String, nil),
}),
Schema: testschema.Schema{
Attributes: map[string]fwschema.Attribute{
"test": testschema.Attribute{
Computed: true,
Type: types.DynamicType,
},
},
},
},
},
resp: ValidateAttributeResponse{},
},
"config-optional-computed-null": {
req: ValidateAttributeRequest{
AttributePath: path.Root("test"),
Expand Down Expand Up @@ -331,6 +354,38 @@ func TestAttributeValidate(t *testing.T) {
},
resp: ValidateAttributeResponse{},
},
"config-required-dynamic-underlying-null-value": {
req: ValidateAttributeRequest{
AttributePath: path.Root("test"),
Config: tfsdk.Config{
Raw: tftypes.NewValue(tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"test": tftypes.DynamicPseudoType,
},
}, map[string]tftypes.Value{
"test": tftypes.NewValue(tftypes.String, nil),
}),
Schema: testschema.Schema{
Attributes: map[string]fwschema.Attribute{
"test": testschema.Attribute{
Required: true,
Type: types.DynamicType,
},
},
},
},
},
resp: ValidateAttributeResponse{
Diagnostics: diag.Diagnostics{
diag.NewAttributeErrorDiagnostic(
path.Root("test"),
"Missing Configuration for Required Attribute",
"Must set a configuration value for the test attribute as the provider has marked it as required.\n\n"+
"Refer to the provider documentation or contact the provider developers for additional information about configurable attributes that are required.",
),
},
},
},
"no-validation": {
req: ValidateAttributeRequest{
AttributePath: path.Root("test"),
Expand Down Expand Up @@ -1763,6 +1818,42 @@ func TestAttributeValidate(t *testing.T) {
},
},
},
"write-only-attr-with-required-dynamic-underlying-null-value": {
req: ValidateAttributeRequest{
ClientCapabilities: validator.ValidateSchemaClientCapabilities{
WriteOnlyAttributesAllowed: true,
},
AttributePath: path.Root("test"),
Config: tfsdk.Config{
Raw: tftypes.NewValue(tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"test": tftypes.DynamicPseudoType,
},
}, map[string]tftypes.Value{
"test": tftypes.NewValue(tftypes.String, nil),
}),
Schema: testschema.Schema{
Attributes: map[string]fwschema.Attribute{
"test": testschema.Attribute{
Type: types.DynamicType,
WriteOnly: true,
Required: true,
},
},
},
},
},
resp: ValidateAttributeResponse{
Diagnostics: diag.Diagnostics{
diag.NewAttributeErrorDiagnostic(
path.Root("test"),
"Missing Configuration for Required Attribute",
"Must set a configuration value for the test attribute as the provider has marked it as required.\n\n"+
"Refer to the provider documentation or contact the provider developers for additional information about configurable attributes that are required.",
),
},
},
},
"write-only-attr-with-optional": {
req: ValidateAttributeRequest{
ClientCapabilities: validator.ValidateSchemaClientCapabilities{
Expand Down
63 changes: 1 addition & 62 deletions internal/fwserver/server_planresourcechange.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/internal/fromtftypes"
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata"
"github.com/hashicorp/terraform-plugin-framework/internal/logging"
Expand Down Expand Up @@ -338,6 +337,7 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange
"This is always an issue in the Terraform Provider and should be reported to the provider developers.\n\n"+
"Ensure all resource plan modifiers do not attempt to change resource plan data from being a null value if the request plan is a null value.",
)
return
}

// Set any write-only attributes in the plan to null
Expand Down Expand Up @@ -513,67 +513,6 @@ func NormaliseRequiresReplace(ctx context.Context, rs path.Paths) path.Paths {
return ret[:j]
}

// RequiredWriteOnlyNilsAttributePaths returns a tftypes.Walk() function
// that populates reqWriteOnlyNilsPaths with the paths of Required and WriteOnly
// attributes that have a null value.
func RequiredWriteOnlyNilsAttributePaths(ctx context.Context, schema fwschema.Schema, reqWriteOnlyNilsPaths *path.Paths) func(path *tftypes.AttributePath, value tftypes.Value) (bool, error) {
return func(attrPath *tftypes.AttributePath, value tftypes.Value) (bool, error) {
// we are only modifying attributes, not the entire resource
if len(attrPath.Steps()) < 1 {
return true, nil
}

ctx = logging.FrameworkWithAttributePath(ctx, attrPath.String())

attribute, err := schema.AttributeAtTerraformPath(ctx, attrPath)

if err != nil {
if errors.Is(err, fwschema.ErrPathInsideAtomicAttribute) {
// atomic attributes can be nested block objects that contain child writeOnly attributes
return true, nil
}

if errors.Is(err, fwschema.ErrPathIsBlock) {
// blocks do not have the writeOnly field but can contain child writeOnly attributes
return true, nil
}

if errors.Is(err, fwschema.ErrPathInsideDynamicAttribute) {
return false, nil
}

logging.FrameworkError(ctx, "couldn't find attribute in resource schema")
return false, fmt.Errorf("couldn't find attribute in resource schema: %w", err)
}

if attribute.IsWriteOnly() {
if attribute.IsRequired() && value.IsNull() {
fwPath, diags := fromtftypes.AttributePath(ctx, attrPath, schema)
if diags.HasError() {
for _, diagErr := range diags.Errors() {
logging.FrameworkError(ctx,
"Error converting tftypes.AttributePath to path.Path",
map[string]any{
logging.KeyError: diagErr.Detail(),
},
)
}

return false, fmt.Errorf("couldn't convert tftypes.AttributePath to path.Path")
}
reqWriteOnlyNilsPaths.Append(fwPath)

// if the value is nil, there is no need to continue walking
return false, nil
}
// check for any writeOnly child attributes
return true, nil
}

return false, nil
}
}

// planToState returns a *tfsdk.State with a copied value from a tfsdk.Plan.
func planToState(plan tfsdk.Plan) *tfsdk.State {
return &tfsdk.State{
Expand Down
Loading
Loading