From 4d843b51667bf1dddffa10a01034a0dd41a6383b Mon Sep 17 00:00:00 2001 From: Brian Flad Date: Thu, 11 Nov 2021 11:17:55 -0500 Subject: [PATCH] tfsdk: Support block plan modification Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/222 Adds `PlanModifiers` within `Block`s and ensures any children attribute and block plan modifiers of a `Block` are executed. The dual response parameter pattern is twofold necessary, or rather a simpler implementation, due to the current details: * `AttributePlanModifier`s return a `RequiresReplace` boolean, which the caller is responsible for setting the proper attribute path for the overall schema response. * `AttributePlanModifier`s return `AttributePlan` as an `attr.Value`, which cannot be directly set into a parent value without reimplementing all the `SetAttribute()` logic for handling nil/null/unknown values, so it is left to the caller to properly call `(Plan).SetAttribute()` in the schema response. --- tfsdk/attribute.go | 178 ++- tfsdk/attribute_plan_modification.go | 11 +- tfsdk/attribute_plan_modification_test.go | 335 ++++++ tfsdk/attribute_test.go | 489 +++++++- tfsdk/block.go | 198 ++++ tfsdk/block_test.go | 1007 +++++++++++++++++ tfsdk/schema.go | 149 +-- tfsdk/serve.go | 2 +- ..._resource_attribute_plan_modifiers_test.go | 37 +- tfsdk/serve_test.go | 68 +- 10 files changed, 2298 insertions(+), 176 deletions(-) diff --git a/tfsdk/attribute.go b/tfsdk/attribute.go index 464d40090..b63d61db8 100644 --- a/tfsdk/attribute.go +++ b/tfsdk/attribute.go @@ -78,8 +78,9 @@ type Attribute struct { // resource-level plan modifications. // // Any errors will prevent further execution of this sequence - // of modifiers and modifiers associated with any nested Attribute, but will not - // prevent execution of PlanModifiers on any other Attribute in the Schema. + // of modifiers and modifiers associated with any nested Attribute, but + // will not prevent execution of PlanModifiers on any other Attribute or + // Block in the Schema. // // Plan modification only applies to resources, not data sources or // providers. Setting PlanModifiers on a data source or provider attribute @@ -477,9 +478,10 @@ func (a Attribute) validateAttributes(ctx context.Context, req ValidateAttribute } // modifyPlan runs all AttributePlanModifiers -func (a Attribute) modifyPlan(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { +func (a Attribute) modifyPlan(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse, schemaResp *ModifySchemaPlanResponse) { attrConfig, diags := req.Config.getAttributeValue(ctx, req.AttributePath) resp.Diagnostics.Append(diags...) + schemaResp.Diagnostics = resp.Diagnostics // Only on new errors. if diags.HasError() { return @@ -488,6 +490,7 @@ func (a Attribute) modifyPlan(ctx context.Context, req ModifyAttributePlanReques attrState, diags := req.State.getAttributeValue(ctx, req.AttributePath) resp.Diagnostics.Append(diags...) + schemaResp.Diagnostics = resp.Diagnostics // Only on new errors. if diags.HasError() { return @@ -496,11 +499,13 @@ func (a Attribute) modifyPlan(ctx context.Context, req ModifyAttributePlanReques attrPlan, diags := req.Plan.getAttributeValue(ctx, req.AttributePath) resp.Diagnostics.Append(diags...) + schemaResp.Diagnostics = resp.Diagnostics // Only on new errors. if diags.HasError() { return } req.AttributePlan = attrPlan + resp.AttributePlan = attrPlan for _, planModifier := range a.PlanModifiers { modifyResp := &ModifyAttributePlanResponse{ @@ -514,10 +519,177 @@ func (a Attribute) modifyPlan(ctx context.Context, req ModifyAttributePlanReques resp.AttributePlan = modifyResp.AttributePlan resp.Diagnostics.Append(modifyResp.Diagnostics...) resp.RequiresReplace = modifyResp.RequiresReplace + schemaResp.Diagnostics = resp.Diagnostics // Only on new errors. if modifyResp.Diagnostics.HasError() { return } } + + if resp.RequiresReplace { + schemaResp.RequiresReplace = append(schemaResp.RequiresReplace, req.AttributePath) + } + + setAttrDiags := schemaResp.Plan.SetAttribute(ctx, req.AttributePath, resp.AttributePlan) + resp.Diagnostics.Append(setAttrDiags...) + schemaResp.Diagnostics = resp.Diagnostics + + if setAttrDiags.HasError() { + return + } + + if !a.definesAttributes() { + return + } + + nm := a.Attributes.GetNestingMode() + switch nm { + case NestingModeList: + l, ok := req.AttributePlan.(types.List) + + if !ok { + err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", req.AttributePlan, nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Plan Modification Error", + "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + for idx := range l.Elems { + for name, attr := range a.Attributes.GetAttributes() { + attrReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyInt(idx).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + attr.modifyPlan(ctx, attrReq, attrResp, schemaResp) + } + } + case NestingModeSet: + s, ok := req.AttributePlan.(types.Set) + + if !ok { + err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", req.AttributePlan, nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Plan Modification Error", + "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + for _, value := range s.Elems { + tfValueRaw, err := value.ToTerraformValue(ctx) + + if err != nil { + err := fmt.Errorf("error running ToTerraformValue on element value: %v", value) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Plan Modification Error", + "Attribute plan modification cannot convert element into a Terraform value. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + tfValue := tftypes.NewValue(s.ElemType.TerraformType(ctx), tfValueRaw) + + for name, attr := range a.Attributes.GetAttributes() { + attrReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyValue(tfValue).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + attr.modifyPlan(ctx, attrReq, attrResp, schemaResp) + } + } + case NestingModeMap: + m, ok := req.AttributePlan.(types.Map) + + if !ok { + err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", req.AttributePlan, nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Plan Modification Error", + "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + for key := range m.Elems { + for name, attr := range a.Attributes.GetAttributes() { + attrReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyString(key).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + attr.modifyPlan(ctx, attrReq, attrResp, schemaResp) + } + } + case NestingModeSingle: + o, ok := req.AttributePlan.(types.Object) + + if !ok { + err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", req.AttributePlan, nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Plan Modification Error", + "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + if len(o.Attrs) == 0 { + return + } + + for name, attr := range a.Attributes.GetAttributes() { + attrReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + attr.modifyPlan(ctx, attrReq, attrResp, schemaResp) + } + default: + err := fmt.Errorf("unknown attribute nesting mode (%T: %v) at path: %s", nm, nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Plan Modification Error", + "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } } diff --git a/tfsdk/attribute_plan_modification.go b/tfsdk/attribute_plan_modification.go index bb4970c69..8cc03fa07 100644 --- a/tfsdk/attribute_plan_modification.go +++ b/tfsdk/attribute_plan_modification.go @@ -2,6 +2,7 @@ package tfsdk import ( "context" + "errors" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -127,7 +128,10 @@ func (r RequiresReplaceModifier) Modify(ctx context.Context, req ModifyAttribute } attrSchema, err := req.State.Schema.AttributeAtPath(req.AttributePath) - if err != nil { + + // Path may lead to block instead of attribute. Blocks cannot be Computed. + // If ErrPathIsBlock, attrSchema.Computed will still be false later. + if err != nil && !errors.Is(err, ErrPathIsBlock) { resp.Diagnostics.AddAttributeError(req.AttributePath, "Error finding attribute schema", fmt.Sprintf("An unexpected error was encountered retrieving the schema for this attribute. This is always a bug in the provider.\n\nError: %s", err), @@ -264,7 +268,10 @@ func (r RequiresReplaceIfModifier) Modify(ctx context.Context, req ModifyAttribu } attrSchema, err := req.State.Schema.AttributeAtPath(req.AttributePath) - if err != nil { + + // Path may lead to block instead of attribute. Blocks cannot be Computed. + // If ErrPathIsBlock, attrSchema.Computed will still be false later. + if err != nil && !errors.Is(err, ErrPathIsBlock) { resp.Diagnostics.AddAttributeError(req.AttributePath, "Error finding attribute schema", fmt.Sprintf("An unexpected error was encountered retrieving the schema for this attribute. This is always a bug in the provider.\n\nError: %s", err), diff --git a/tfsdk/attribute_plan_modification_test.go b/tfsdk/attribute_plan_modification_test.go index cada22ece..21a335d5f 100644 --- a/tfsdk/attribute_plan_modification_test.go +++ b/tfsdk/attribute_plan_modification_test.go @@ -188,6 +188,25 @@ func TestRequiresReplaceModifier(t *testing.T) { }, } + blockSchema := Schema{ + Blocks: map[string]Block{ + "block": { + Attributes: map[string]Attribute{ + "optional-computed": { + Type: types.StringType, + Optional: true, + Computed: true, + }, + "optional": { + Type: types.StringType, + Optional: true, + }, + }, + NestingMode: BlockNestingModeList, + }, + }, + } + tests := map[string]testCase{ "null-state": { // when we first create the resource, it shouldn't @@ -425,6 +444,322 @@ func TestRequiresReplaceModifier(t *testing.T) { expectedPlan: types.String{Null: true}, expectedRR: true, }, + "block-no-change": { + state: State{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "samevalue"), + }), + }), + }), + }, + plan: Plan{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "samevalue"), + }), + }), + }), + }, + config: Config{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "samevalue"), + }), + }), + }), + }, + path: tftypes.NewAttributePath().WithAttributeName("block"), + expectedPlan: types.List{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + }, + Elems: []attr.Value{ + types.Object{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + Attrs: map[string]attr.Value{ + "optional-computed": types.String{Value: "samevalue"}, + "optional": types.String{Value: "samevalue"}, + }, + }, + }, + }, + expectedRR: false, + }, + "block-element-count-change": { + state: State{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "samevalue"), + }), + }), + }), + }, + plan: Plan{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "samevalue"), + }), + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "newvalue"), + "optional": tftypes.NewValue(tftypes.String, "newvalue"), + }), + }), + }), + }, + config: Config{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "samevalue"), + }), + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "newvalue"), + "optional": tftypes.NewValue(tftypes.String, "newvalue"), + }), + }), + }), + }, + path: tftypes.NewAttributePath().WithAttributeName("block"), + expectedPlan: types.List{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + }, + Elems: []attr.Value{ + types.Object{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + Attrs: map[string]attr.Value{ + "optional-computed": types.String{Value: "samevalue"}, + "optional": types.String{Value: "samevalue"}, + }, + }, + types.Object{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + Attrs: map[string]attr.Value{ + "optional-computed": types.String{Value: "newvalue"}, + "optional": types.String{Value: "newvalue"}, + }, + }, + }, + }, + expectedRR: true, + }, + "block-nested-attribute-change": { + state: State{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "oldvalue"), + }), + }), + }), + }, + plan: Plan{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "newvalue"), + }), + }), + }), + }, + config: Config{ + Schema: blockSchema, + Raw: tftypes.NewValue(blockSchema.TerraformType(context.Background()), map[string]tftypes.Value{ + "block": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "optional-computed": tftypes.String, + "optional": tftypes.String, + }, + }, map[string]tftypes.Value{ + "optional-computed": tftypes.NewValue(tftypes.String, "samevalue"), + "optional": tftypes.NewValue(tftypes.String, "newvalue"), + }), + }), + }), + }, + path: tftypes.NewAttributePath().WithAttributeName("block"), + expectedPlan: types.List{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + }, + Elems: []attr.Value{ + types.Object{ + AttrTypes: map[string]attr.Type{ + "optional-computed": types.StringType, + "optional": types.StringType, + }, + Attrs: map[string]attr.Value{ + "optional-computed": types.String{Value: "samevalue"}, + "optional": types.String{Value: "newvalue"}, + }, + }, + }, + }, + expectedRR: true, + }, } for name, tc := range tests { diff --git a/tfsdk/attribute_test.go b/tfsdk/attribute_test.go index 01541d6ed..a3ee6c3be 100644 --- a/tfsdk/attribute_test.go +++ b/tfsdk/attribute_test.go @@ -698,9 +698,10 @@ func TestAttributeModifyPlan(t *testing.T) { t.Parallel() testCases := map[string]struct { - req ModifyAttributePlanRequest - resp ModifyAttributePlanResponse - expectedResp ModifyAttributePlanResponse + req ModifyAttributePlanRequest + resp ModifyAttributePlanResponse + expectedResp ModifyAttributePlanResponse + expectedSchemaResp ModifySchemaPlanResponse }{ "config-error": { req: ModifyAttributePlanRequest{ @@ -771,6 +772,33 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + tftypes.NewAttributePath().WithAttributeName("test"), + "Configuration Read Error", + "An unexpected error was encountered trying to read an attribute from the configuration. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "can't use tftypes.String<\"testvalue\"> as value of List with ElementType types.primitive, can only use tftypes.String values", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + }, }, "config-error-previous-error": { req: ModifyAttributePlanRequest{ @@ -851,6 +879,37 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewAttributeErrorDiagnostic( + tftypes.NewAttributePath().WithAttributeName("test"), + "Configuration Read Error", + "An unexpected error was encountered trying to read an attribute from the configuration. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "can't use tftypes.String<\"testvalue\"> as value of List with ElementType types.primitive, can only use tftypes.String values", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + }, }, "plan-error": { req: ModifyAttributePlanRequest{ @@ -921,6 +980,33 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + tftypes.NewAttributePath().WithAttributeName("test"), + "Plan Read Error", + "An unexpected error was encountered trying to read an attribute from the plan. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "can't use tftypes.String<\"testvalue\"> as value of List with ElementType types.primitive, can only use tftypes.String values", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ElemType: types.StringType}, + Required: true, + }, + }, + }, + }, + }, }, "plan-error-previous-error": { req: ModifyAttributePlanRequest{ @@ -1001,6 +1087,37 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewAttributeErrorDiagnostic( + tftypes.NewAttributePath().WithAttributeName("test"), + "Plan Read Error", + "An unexpected error was encountered trying to read an attribute from the plan. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "can't use tftypes.String<\"testvalue\"> as value of List with ElementType types.primitive, can only use tftypes.String values", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ElemType: types.StringType}, + Required: true, + }, + }, + }, + }, + }, }, "state-error": { req: ModifyAttributePlanRequest{ @@ -1071,6 +1188,33 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + tftypes.NewAttributePath().WithAttributeName("test"), + "State Read Error", + "An unexpected error was encountered trying to read an attribute from the state. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "can't use tftypes.String<\"testvalue\"> as value of List with ElementType types.primitive, can only use tftypes.String values", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + }, }, "state-error-previous-error": { req: ModifyAttributePlanRequest{ @@ -1151,6 +1295,37 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewAttributeErrorDiagnostic( + tftypes.NewAttributePath().WithAttributeName("test"), + "State Read Error", + "An unexpected error was encountered trying to read an attribute from the state. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "can't use tftypes.String<\"testvalue\"> as value of List with ElementType types.primitive, can only use tftypes.String values", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + }, }, "no-plan-modifiers": { req: ModifyAttributePlanRequest{ @@ -1213,6 +1388,25 @@ func TestAttributeModifyPlan(t *testing.T) { expectedResp: ModifyAttributePlanResponse{ AttributePlan: types.String{Value: "testvalue"}, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + }, }, "attribute-plan": { req: ModifyAttributePlanRequest{ @@ -1287,6 +1481,29 @@ func TestAttributeModifyPlan(t *testing.T) { expectedResp: ModifyAttributePlanResponse{ AttributePlan: types.String{Value: "MODIFIED_TWO"}, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "MODIFIED_TWO"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + PlanModifiers: []AttributePlanModifier{ + testAttrPlanValueModifierOne{}, + testAttrPlanValueModifierTwo{}, + }, + }, + }, + }, + }, + }, }, "attribute-plan-previous-error": { req: ModifyAttributePlanRequest{ @@ -1373,6 +1590,35 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "MODIFIED_TWO"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + PlanModifiers: []AttributePlanModifier{ + testAttrPlanValueModifierOne{}, + testAttrPlanValueModifierTwo{}, + }, + }, + }, + }, + }, + }, }, "requires-replacement": { req: ModifyAttributePlanRequest{ @@ -1439,12 +1685,34 @@ func TestAttributeModifyPlan(t *testing.T) { }, }, resp: ModifyAttributePlanResponse{ - AttributePlan: types.String{Value: "testvalue"}, + AttributePlan: types.String{Value: "newtestvalue"}, }, expectedResp: ModifyAttributePlanResponse{ - AttributePlan: types.String{Value: "testvalue"}, + AttributePlan: types.String{Value: "newtestvalue"}, RequiresReplace: true, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "newtestvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test"), + }, + }, }, "requires-replacement-previous-error": { req: ModifyAttributePlanRequest{ @@ -1511,7 +1779,7 @@ func TestAttributeModifyPlan(t *testing.T) { }, }, resp: ModifyAttributePlanResponse{ - AttributePlan: types.String{Value: "testvalue"}, + AttributePlan: types.String{Value: "newtestvalue"}, Diagnostics: diag.Diagnostics{ diag.NewErrorDiagnostic( "Previous error diag", @@ -1520,7 +1788,7 @@ func TestAttributeModifyPlan(t *testing.T) { }, }, expectedResp: ModifyAttributePlanResponse{ - AttributePlan: types.String{Value: "testvalue"}, + AttributePlan: types.String{Value: "newtestvalue"}, Diagnostics: diag.Diagnostics{ diag.NewErrorDiagnostic( "Previous error diag", @@ -1529,6 +1797,34 @@ func TestAttributeModifyPlan(t *testing.T) { }, RequiresReplace: true, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "newtestvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test"), + }, + }, }, "requires-replacement-passthrough": { req: ModifyAttributePlanRequest{ @@ -1604,6 +1900,28 @@ func TestAttributeModifyPlan(t *testing.T) { AttributePlan: types.String{Value: "TESTATTRTWO"}, RequiresReplace: true, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "TESTATTRTWO"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test"), + }, + }, }, "requires-replacement-unset": { req: ModifyAttributePlanRequest{ @@ -1678,6 +1996,25 @@ func TestAttributeModifyPlan(t *testing.T) { expectedResp: ModifyAttributePlanResponse{ AttributePlan: types.String{Value: "testvalue"}, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + }, + }, + }, + }, + }, }, "warnings": { req: ModifyAttributePlanRequest{ @@ -1761,6 +2098,38 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "TESTDIAG"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + PlanModifiers: []AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }, + }, + }, + }, + }, + }, }, "warnings-previous-error": { req: ModifyAttributePlanRequest{ @@ -1854,6 +2223,42 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "TESTDIAG"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + PlanModifiers: []AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }, + }, + }, + }, + }, + }, }, "error": { req: ModifyAttributePlanRequest{ @@ -1934,6 +2339,35 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "TESTDIAG"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + PlanModifiers: []AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }, + }, + }, + }, + }, + }, }, "error-previous-error": { req: ModifyAttributePlanRequest{ @@ -2024,6 +2458,39 @@ func TestAttributeModifyPlan(t *testing.T) { ), }, }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + Plan: Plan{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "TESTDIAG"), + }), + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + Required: true, + PlanModifiers: []AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }, + }, + }, + }, + }, + }, }, } @@ -2038,11 +2505,17 @@ func TestAttributeModifyPlan(t *testing.T) { t.Fatalf("Unexpected error getting %s", err) } - attribute.modifyPlan(context.Background(), tc.req, &tc.resp) + schemaResp := ModifySchemaPlanResponse{Plan: tc.req.Plan} + + attribute.modifyPlan(context.Background(), tc.req, &tc.resp, &schemaResp) if diff := cmp.Diff(tc.expectedResp, tc.resp); diff != "" { t.Errorf("Unexpected response (-wanted, +got): %s", diff) } + + if diff := cmp.Diff(tc.expectedSchemaResp, schemaResp); diff != "" { + t.Errorf("Unexpected response (+wanted, -got): %s", diff) + } }) } } diff --git a/tfsdk/block.go b/tfsdk/block.go index 264e49a52..475547a8b 100644 --- a/tfsdk/block.go +++ b/tfsdk/block.go @@ -54,6 +54,20 @@ type Block struct { // NestingMode indicates the block kind. NestingMode BlockNestingMode + // PlanModifiers defines a sequence of modifiers for this block at + // plan time. Block-level plan modifications occur before any + // resource-level plan modifications. + // + // Any errors will prevent further execution of this sequence + // of modifiers and modifiers associated with any nested Attribute or + // Block, but will not prevent execution of PlanModifiers on any + // other Attribute or Block in the Schema. + // + // Plan modification only applies to resources, not data sources or + // providers. Setting PlanModifiers on a data source or provider attribute + // will have no effect. + PlanModifiers AttributePlanModifiers + // Validators defines validation functionality for the block. Validators []AttributeValidator } @@ -140,6 +154,190 @@ func (b Block) attributeType() attr.Type { } } +// modifyPlan performs all Block plan modification. +func (b Block) modifyPlan(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse, schemaResp *ModifySchemaPlanResponse) { + attributeConfig, diags := req.Config.getAttributeValue(ctx, req.AttributePath) + resp.Diagnostics.Append(diags...) + schemaResp.Diagnostics = resp.Diagnostics + + if diags.HasError() { + return + } + + req.AttributeConfig = attributeConfig + + attributePlan, diags := req.Plan.getAttributeValue(ctx, req.AttributePath) + resp.Diagnostics.Append(diags...) + schemaResp.Diagnostics = resp.Diagnostics + + if diags.HasError() { + return + } + + req.AttributePlan = attributePlan + resp.AttributePlan = attributePlan + + attributeState, diags := req.State.getAttributeValue(ctx, req.AttributePath) + resp.Diagnostics.Append(diags...) + schemaResp.Diagnostics = resp.Diagnostics + + if diags.HasError() { + return + } + + req.AttributeState = attributeState + + for _, planModifier := range b.PlanModifiers { + modifyResp := &ModifyAttributePlanResponse{ + AttributePlan: resp.AttributePlan, + RequiresReplace: resp.RequiresReplace, + } + + planModifier.Modify(ctx, req, modifyResp) + + req.AttributePlan = modifyResp.AttributePlan + resp.AttributePlan = modifyResp.AttributePlan + resp.Diagnostics.Append(modifyResp.Diagnostics...) + resp.RequiresReplace = modifyResp.RequiresReplace + schemaResp.Diagnostics = resp.Diagnostics + + // Only on new errors. + if modifyResp.Diagnostics.HasError() { + return + } + } + + if resp.RequiresReplace { + schemaResp.RequiresReplace = append(schemaResp.RequiresReplace, req.AttributePath) + } + + setAttrDiags := schemaResp.Plan.SetAttribute(ctx, req.AttributePath, resp.AttributePlan) + resp.Diagnostics.Append(setAttrDiags...) + schemaResp.Diagnostics = resp.Diagnostics + + if setAttrDiags.HasError() { + return + } + + nm := b.NestingMode + switch nm { + case BlockNestingModeList: + l, ok := req.AttributePlan.(types.List) + + if !ok { + err := fmt.Errorf("unknown block value type (%s) for nesting mode (%T) at path: %s", req.AttributeConfig.Type(ctx), nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Block Plan Modification Error", + "Block validation cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + for idx := range l.Elems { + for name, attr := range b.Attributes { + attrReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyInt(idx).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + attr.modifyPlan(ctx, attrReq, attrResp, schemaResp) + } + + for name, block := range b.Blocks { + blockReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyInt(idx).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + blockResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + block.modifyPlan(ctx, blockReq, blockResp, schemaResp) + } + } + case BlockNestingModeSet: + s, ok := req.AttributePlan.(types.Set) + + if !ok { + err := fmt.Errorf("unknown block value type (%s) for nesting mode (%T) at path: %s", req.AttributeConfig.Type(ctx), nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Block Plan Modification Error", + "Block plan modification cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + for _, value := range s.Elems { + tfValueRaw, err := value.ToTerraformValue(ctx) + + if err != nil { + err := fmt.Errorf("error running ToTerraformValue on element value: %v", value) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Block Plan Modification Error", + "Block plan modification cannot convert element into a Terraform value. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } + + tfValue := tftypes.NewValue(s.ElemType.TerraformType(ctx), tfValueRaw) + + for name, attr := range b.Attributes { + attrReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyValue(tfValue).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + attr.modifyPlan(ctx, attrReq, attrResp, schemaResp) + } + + for name, block := range b.Blocks { + blockReq := ModifyAttributePlanRequest{ + AttributePath: req.AttributePath.WithElementKeyValue(tfValue).WithAttributeName(name), + Config: req.Config, + Plan: schemaResp.Plan, + ProviderMeta: req.ProviderMeta, + State: req.State, + } + blockResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, + } + + block.modifyPlan(ctx, blockReq, blockResp, schemaResp) + } + } + default: + err := fmt.Errorf("unknown block plan modification nesting mode (%T: %v) at path: %s", nm, nm, req.AttributePath) + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Block Plan Modification Error", + "Block plan modification cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), + ) + + return + } +} + // terraformType returns an tftypes.Type corresponding to the block. func (b Block) terraformType(ctx context.Context) tftypes.Type { return b.attributeType().TerraformType(ctx) diff --git a/tfsdk/block_test.go b/tfsdk/block_test.go index 3120b33f7..cdad4f41d 100644 --- a/tfsdk/block_test.go +++ b/tfsdk/block_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" testtypes "github.com/hashicorp/terraform-plugin-framework/internal/testing/types" "github.com/hashicorp/terraform-plugin-framework/types" @@ -12,6 +13,986 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) +func TestBlockModifyPlan(t *testing.T) { + t.Parallel() + + blockValue := func(elementValue string) attr.Value { + return types.List{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "nested_attr": types.StringType, + }, + }, + Elems: []attr.Value{ + types.Object{ + AttrTypes: map[string]attr.Type{ + "nested_attr": types.StringType, + }, + Attrs: map[string]attr.Value{ + "nested_attr": types.String{Value: elementValue}, + }, + }, + }, + } + } + + var blockNullValue attr.Value = types.List{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "nested_attr": types.StringType, + }, + }, + Null: true, + } + + schema := func(blockPlanModifiers AttributePlanModifiers, nestedAttrPlanModifiers AttributePlanModifiers) Schema { + return Schema{ + Blocks: map[string]Block{ + "test": { + Attributes: map[string]Attribute{ + "nested_attr": { + Type: types.StringType, + Required: true, + PlanModifiers: nestedAttrPlanModifiers, + }, + }, + NestingMode: BlockNestingModeList, + PlanModifiers: blockPlanModifiers, + }, + }, + } + } + + schemaTfValue := func(nestedAttrValue string) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr": tftypes.String, + }, + }, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr": tftypes.String, + }, + }, + }, + []tftypes.Value{ + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "nested_attr": tftypes.NewValue(tftypes.String, nestedAttrValue), + }, + ), + }, + ), + }, + ) + } + + var schemaNullTfValue tftypes.Value = tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr": tftypes.String, + }, + }, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr": tftypes.String, + }, + }, + }, + nil, + ), + }, + ) + + modifyAttributePlanRequest := func(attrPath *tftypes.AttributePath, schema Schema, configValue, planValue, stateValue string) ModifyAttributePlanRequest { + return ModifyAttributePlanRequest{ + AttributePath: attrPath, + Config: Config{ + Raw: schemaTfValue(configValue), + Schema: schema, + }, + Plan: Plan{ + Raw: schemaTfValue(planValue), + Schema: schema, + }, + State: State{ + Raw: schemaTfValue(stateValue), + Schema: schema, + }, + } + } + + testCases := map[string]struct { + req ModifyAttributePlanRequest + resp ModifyAttributePlanResponse + expectedResp ModifyAttributePlanResponse + expectedSchemaResp ModifySchemaPlanResponse + }{ + "no-plan-modifiers": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, nil), + "testvalue", + "testvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("testvalue"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("testvalue"), + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("testvalue"), + Schema: schema(nil, nil), + }, + }, + }, + "block-modified": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + testBlockPlanModifierNullList{}, + }, nil), + "TESTATTRONE", + "TESTATTRONE", + "TESTATTRONE", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTATTRONE"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockNullValue, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaNullTfValue, + Schema: schema([]AttributePlanModifier{ + testBlockPlanModifierNullList{}, + }, nil), + }, + }, + }, + "block-modified-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + testBlockPlanModifierNullList{}, + }, nil), + "TESTATTRONE", + "TESTATTRONE", + "TESTATTRONE", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTATTRONE"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockNullValue, + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + Plan: Plan{ + Raw: schemaNullTfValue, + Schema: schema([]AttributePlanModifier{ + testBlockPlanModifierNullList{}, + }, nil), + }, + }, + }, + "block-requires-replacement": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + RequiresReplace(), + }, nil), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + RequiresReplace: true, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("newtestvalue"), + Schema: schema([]AttributePlanModifier{ + RequiresReplace(), + }, nil), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test"), + }, + }, + }, + "block-requires-replacement-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + RequiresReplace(), + }, nil), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + RequiresReplace: true, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("newtestvalue"), + Schema: schema([]AttributePlanModifier{ + RequiresReplace(), + }, nil), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test"), + }, + }, + }, + "block-requires-replacement-passthrough": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + RequiresReplace(), + testBlockPlanModifierNullList{}, + }, nil), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockNullValue, + RequiresReplace: true, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaNullTfValue, + Schema: schema([]AttributePlanModifier{ + RequiresReplace(), + testBlockPlanModifierNullList{}, + }, nil), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test"), + }, + }, + }, + "block-requires-replacement-unset": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + RequiresReplace(), + testRequiresReplaceFalseModifier{}, + }, nil), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("newtestvalue"), + Schema: schema([]AttributePlanModifier{ + RequiresReplace(), + testRequiresReplaceFalseModifier{}, + }, nil), + }, + }, + }, + "block-warnings": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }, nil), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema([]AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }, nil), + }, + }, + }, + "block-warnings-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }, nil), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema([]AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }, nil), + }, + }, + }, + "block-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }, nil), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema([]AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }, nil), + }, + }, + }, + "block-error-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema([]AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }, nil), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema([]AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }, nil), + }, + }, + }, + "nested-attribute-modified": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + testAttrPlanValueModifierOne{}, + testAttrPlanValueModifierTwo{}, + }), + "TESTATTRONE", + "TESTATTRONE", + "TESTATTRONE", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTATTRONE"), + }, + expectedResp: ModifyAttributePlanResponse{ + // This value is not expected to be updated here since plan + // modification occurred outside the block itself. + // See the schema response instead. + AttributePlan: blockValue("TESTATTRONE"), + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("MODIFIED_TWO"), + Schema: schema(nil, []AttributePlanModifier{ + testAttrPlanValueModifierOne{}, + testAttrPlanValueModifierTwo{}, + }), + }, + }, + }, + "nested-attribute-modified-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + testAttrPlanValueModifierOne{}, + testAttrPlanValueModifierTwo{}, + }), + "TESTATTRONE", + "TESTATTRONE", + "TESTATTRONE", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTATTRONE"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + // This value is not expected to be updated here since plan + // modification occurred outside the block itself. + // See the schema response instead. + AttributePlan: blockValue("TESTATTRONE"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("MODIFIED_TWO"), + Schema: schema(nil, []AttributePlanModifier{ + testAttrPlanValueModifierOne{}, + testAttrPlanValueModifierTwo{}, + }), + }, + }, + }, + "nested-attribute-requires-replacement": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + RequiresReplace(), + }), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("newtestvalue"), + Schema: schema(nil, []AttributePlanModifier{ + RequiresReplace(), + }), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test").WithElementKeyInt(0).WithAttributeName("nested_attr"), + }, + }, + }, + "nested-attribute-requires-replacement-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + RequiresReplace(), + }), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("newtestvalue"), + Schema: schema(nil, []AttributePlanModifier{ + RequiresReplace(), + }), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test").WithElementKeyInt(0).WithAttributeName("nested_attr"), + }, + }, + }, + "nested-attribute-requires-replacement-passthrough": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + RequiresReplace(), + testAttrPlanValueModifierOne{}, + }), + "TESTATTRONE", + "TESTATTRONE", + "previousvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTATTRONE"), + }, + expectedResp: ModifyAttributePlanResponse{ + // This value is not expected to be updated here since plan + // modification occurred outside the block itself. + // See the schema response instead. + AttributePlan: blockValue("TESTATTRONE"), + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("TESTATTRTWO"), + Schema: schema(nil, []AttributePlanModifier{ + RequiresReplace(), + testAttrPlanValueModifierOne{}, + }), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("test").WithElementKeyInt(0).WithAttributeName("nested_attr"), + }, + }, + }, + "nested-attribute-requires-replacement-unset": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + RequiresReplace(), + testRequiresReplaceFalseModifier{}, + }), + "newtestvalue", + "newtestvalue", + "testvalue", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("newtestvalue"), + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Plan: Plan{ + Raw: schemaTfValue("newtestvalue"), + Schema: schema(nil, []AttributePlanModifier{ + RequiresReplace(), + testRequiresReplaceFalseModifier{}, + }), + }, + }, + }, + "nested-attribute-warnings": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + // This additional diagnostic is not expected here since plan + // modification error occurred outside the block itself. + // See the schema response instead. + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema(nil, []AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }), + }, + }, + }, + "nested-attribute-warnings-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + // This additional diagnostic is not expected here since plan + // modification error occurred outside the block itself. + // See the schema response instead. + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + // Diagnostics.Append() deduplicates, so the warning will only + // be here once unless the test implementation is changed to + // different modifiers or the modifier itself is changed. + diag.NewWarningDiagnostic( + "Warning diag", + "This is a warning", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema(nil, []AttributePlanModifier{ + testWarningDiagModifier{}, + testWarningDiagModifier{}, + }), + }, + }, + }, + "nested-attribute-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + // This additional diagnostic is not expected here since plan + // modification error occurred outside the block itself. + // See the schema response instead. + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema(nil, []AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }), + }, + }, + }, + "nested-attribute-error-previous-error": { + req: modifyAttributePlanRequest( + tftypes.NewAttributePath().WithAttributeName("test"), + schema(nil, []AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }), + "TESTDIAG", + "TESTDIAG", + "TESTDIAG", + ), + resp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + }, + }, + expectedResp: ModifyAttributePlanResponse{ + AttributePlan: blockValue("TESTDIAG"), + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + // This additional diagnostic is not expected here since plan + // modification error occurred outside the block itself. + // See the schema response instead. + }, + }, + expectedSchemaResp: ModifySchemaPlanResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Previous error diag", + "This was a previous error", + ), + diag.NewErrorDiagnostic( + "Error diag", + "This is an error", + ), + }, + Plan: Plan{ + Raw: schemaTfValue("TESTDIAG"), + Schema: schema(nil, []AttributePlanModifier{ + testErrorDiagModifier{}, + testErrorDiagModifier{}, + }), + }, + }, + }, + } + + for name, tc := range testCases { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + block, err := tc.req.Config.Schema.blockAtPath(tc.req.AttributePath) + + if err != nil { + t.Fatalf("Unexpected error getting %s", err) + } + + schemaResp := ModifySchemaPlanResponse{Plan: tc.req.Plan} + + block.modifyPlan(context.Background(), tc.req, &tc.resp, &schemaResp) + + if diff := cmp.Diff(tc.expectedResp, tc.resp); diff != "" { + t.Errorf("Unexpected response (+wanted, -got): %s", diff) + } + + if diff := cmp.Diff(tc.expectedSchemaResp, schemaResp); diff != "" { + t.Errorf("Unexpected response (+wanted, -got): %s", diff) + } + }) + } +} + func TestBlockTfprotov6(t *testing.T) { t.Parallel() @@ -1300,3 +2281,29 @@ func TestBlockValidate(t *testing.T) { }) } } + +type testBlockPlanModifierNullList struct{} + +func (t testBlockPlanModifierNullList) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { + _, ok := req.AttributePlan.(types.List) + if !ok { + return + } + + resp.AttributePlan = types.List{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "nested_attr": types.StringType, + }, + }, + Null: true, + } +} + +func (t testBlockPlanModifierNullList) Description(ctx context.Context) string { + return "This plan modifier is for use during testing only" +} + +func (t testBlockPlanModifierNullList) MarkdownDescription(ctx context.Context) string { + return "This plan modifier is for use during testing only" +} diff --git a/tfsdk/schema.go b/tfsdk/schema.go index b6d8d960b..bb9f7acee 100644 --- a/tfsdk/schema.go +++ b/tfsdk/schema.go @@ -18,6 +18,10 @@ var ( // it's an element, attribute, or block of a complex type, not a nested // attribute. ErrPathInsideAtomicAttribute = errors.New("path leads to element, attribute, or block of a schema.Attribute that has no schema associated with it") + + // ErrPathIsBlock is used with AttributeAtPath is called on a path is a + // block, not an attribute. Use blockAtPath on the path instead. + ErrPathIsBlock = errors.New("path leads to block, not an attribute") ) // Schema is used to define the shape of practitioner-provider information, @@ -160,7 +164,7 @@ func (s Schema) AttributeAtPath(path *tftypes.AttributePath) (Attribute, error) case Attribute: return r, nil case Block: - return Attribute{}, ErrPathInsideAtomicAttribute + return Attribute{}, ErrPathIsBlock default: return Attribute{}, fmt.Errorf("got unexpected type %T", res) } @@ -303,138 +307,35 @@ func (s Schema) validate(ctx context.Context, req ValidateSchemaRequest, resp *V } } -// modifyAttributePlans runs all AttributePlanModifiers in all schema attributes -func (s Schema) modifyAttributePlans(ctx context.Context, req ModifySchemaPlanRequest, resp *ModifySchemaPlanResponse) { - modifyAttributesPlans(ctx, s.Attributes, tftypes.NewAttributePath(), req, resp) -} - -func modifyAttributesPlans(ctx context.Context, attrs map[string]Attribute, path *tftypes.AttributePath, req ModifySchemaPlanRequest, resp *ModifySchemaPlanResponse) { - for name, nestedAttr := range attrs { - attrPath := path.WithAttributeName(name) - attrPlan, diags := req.Plan.getAttributeValue(ctx, attrPath) - resp.Diagnostics.Append(diags...) - if diags.HasError() { - continue - } - nestedAttrReq := ModifyAttributePlanRequest{ - AttributePath: attrPath, +// modifyPlan runs all AttributePlanModifiers in all schema attributes and blocks +func (s Schema) modifyPlan(ctx context.Context, req ModifySchemaPlanRequest, resp *ModifySchemaPlanResponse) { + for name, attr := range s.Attributes { + attrReq := ModifyAttributePlanRequest{ + AttributePath: tftypes.NewAttributePath().WithAttributeName(name), Config: req.Config, State: req.State, Plan: req.Plan, ProviderMeta: req.ProviderMeta, } - nestedAttrResp := &ModifyAttributePlanResponse{ - AttributePlan: attrPlan, - Diagnostics: resp.Diagnostics, + attrResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, } - nestedAttr.modifyPlan(ctx, nestedAttrReq, nestedAttrResp) - if nestedAttrResp.RequiresReplace { - resp.RequiresReplace = append(resp.RequiresReplace, attrPath) - } + attr.modifyPlan(ctx, attrReq, attrResp, resp) + } - setAttrDiags := resp.Plan.SetAttribute(ctx, attrPath, nestedAttrResp.AttributePlan) - resp.Diagnostics.Append(setAttrDiags...) - if setAttrDiags.HasError() { - continue + for name, block := range s.Blocks { + blockReq := ModifyAttributePlanRequest{ + AttributePath: tftypes.NewAttributePath().WithAttributeName(name), + Config: req.Config, + State: req.State, + Plan: req.Plan, + ProviderMeta: req.ProviderMeta, } - resp.Diagnostics = nestedAttrResp.Diagnostics - - if nestedAttr.definesAttributes() { - nm := nestedAttr.Attributes.GetNestingMode() - switch nm { - case NestingModeList: - l, ok := attrPlan.(types.List) - - if !ok { - err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", attrPlan, nm, attrPath) - resp.Diagnostics.AddAttributeError( - attrPath, - "Attribute Plan Modification Error", - "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), - ) - - continue - } - - for idx := range l.Elems { - modifyAttributesPlans(ctx, nestedAttr.Attributes.GetAttributes(), attrPath.WithElementKeyInt(idx), req, resp) - } - case NestingModeSet: - s, ok := attrPlan.(types.Set) - - if !ok { - err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", attrPlan, nm, attrPath) - resp.Diagnostics.AddAttributeError( - attrPath, - "Attribute Plan Modification Error", - "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), - ) - - return - } - - for _, value := range s.Elems { - tfValueRaw, err := value.ToTerraformValue(ctx) - - if err != nil { - err := fmt.Errorf("error running ToTerraformValue on element value: %v", value) - resp.Diagnostics.AddAttributeError( - attrPath, - "Attribute Plan Modification Error", - "Attribute plan modification cannot convert element into a Terraform value. Report this to the provider developer:\n\n"+err.Error(), - ) - - return - } - - tfValue := tftypes.NewValue(s.ElemType.TerraformType(ctx), tfValueRaw) - - modifyAttributesPlans(ctx, nestedAttr.Attributes.GetAttributes(), attrPath.WithElementKeyValue(tfValue), req, resp) - } - case NestingModeMap: - m, ok := attrPlan.(types.Map) - - if !ok { - err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", attrPlan, nm, attrPath) - resp.Diagnostics.AddAttributeError( - attrPath, - "Attribute Plan Modification Error", - "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), - ) - - continue - } - - for key := range m.Elems { - modifyAttributesPlans(ctx, nestedAttr.Attributes.GetAttributes(), attrPath.WithElementKeyString(key), req, resp) - } - case NestingModeSingle: - o, ok := attrPlan.(types.Object) - - if !ok { - err := fmt.Errorf("unknown attribute value type (%T) for nesting mode (%T) at path: %s", attrPlan, nm, attrPath) - resp.Diagnostics.AddAttributeError( - attrPath, - "Attribute Plan Modification Error", - "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), - ) - - continue - } - if len(o.Attrs) > 0 { - modifyAttributesPlans(ctx, nestedAttr.Attributes.GetAttributes(), attrPath, req, resp) - } - default: - err := fmt.Errorf("unknown attribute nesting mode (%T: %v) at path: %s", nm, nm, attrPath) - resp.Diagnostics.AddAttributeError( - attrPath, - "Attribute Plan Modification Error", - "Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(), - ) - - continue - } + blockResp := &ModifyAttributePlanResponse{ + Diagnostics: resp.Diagnostics, } + + block.modifyPlan(ctx, blockReq, blockResp, resp) } } diff --git a/tfsdk/serve.go b/tfsdk/serve.go index a84f82112..97ea27a97 100644 --- a/tfsdk/serve.go +++ b/tfsdk/serve.go @@ -807,7 +807,7 @@ func (s *server) planResourceChange(ctx context.Context, req *tfprotov6.PlanReso Diagnostics: resp.Diagnostics, } - resourceSchema.modifyAttributePlans(ctx, modifySchemaPlanReq, &modifySchemaPlanResp) + resourceSchema.modifyPlan(ctx, modifySchemaPlanReq, &modifySchemaPlanResp) resp.RequiresReplace = append(resp.RequiresReplace, modifySchemaPlanResp.RequiresReplace...) plan = modifySchemaPlanResp.Plan.Raw resp.Diagnostics = modifySchemaPlanResp.Diagnostics diff --git a/tfsdk/serve_resource_attribute_plan_modifiers_test.go b/tfsdk/serve_resource_attribute_plan_modifiers_test.go index 23ce89ca0..ab6aab4a4 100644 --- a/tfsdk/serve_resource_attribute_plan_modifiers_test.go +++ b/tfsdk/serve_resource_attribute_plan_modifiers_test.go @@ -18,12 +18,11 @@ func (rt testServeResourceTypeAttributePlanModifiers) GetSchema(_ context.Contex "name": { Required: true, Type: types.StringType, - // For the purposes of testing, these plan modifiers behave - // differently for certain values of the attribute. - // By default, they do nothing. PlanModifiers: []AttributePlanModifier{ testWarningDiagModifier{}, - testErrorDiagModifier{}, + // For the purposes of testing, these plan modifiers behave + // differently for certain values of the attribute. + // By default, they do nothing. testAttrPlanValueModifierOne{}, testAttrPlanValueModifierTwo{}, }, @@ -210,17 +209,10 @@ type testServeResourceTypeAttributePlanModifiers struct{} type testWarningDiagModifier struct{} func (t testWarningDiagModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { - attrVal, ok := req.AttributePlan.(types.String) - if !ok { - return - } - - if attrVal.Value == "TESTDIAG" { - resp.Diagnostics.AddWarning( - "Warning diag", - "This is a warning", - ) - } + resp.Diagnostics.AddWarning( + "Warning diag", + "This is a warning", + ) } func (t testWarningDiagModifier) Description(ctx context.Context) string { @@ -234,17 +226,10 @@ func (t testWarningDiagModifier) MarkdownDescription(ctx context.Context) string type testErrorDiagModifier struct{} func (t testErrorDiagModifier) Modify(ctx context.Context, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { - attrVal, ok := req.AttributePlan.(types.String) - if !ok { - return - } - - if attrVal.Value == "TESTDIAG" { - resp.Diagnostics.AddError( - "Error diag", - "This is an error", - ) - } + resp.Diagnostics.AddError( + "Error diag", + "This is an error", + ) } func (t testErrorDiagModifier) Description(ctx context.Context) string { diff --git a/tfsdk/serve_test.go b/tfsdk/serve_test.go index 239e06de3..2f2d9dff0 100644 --- a/tfsdk/serve_test.go +++ b/tfsdk/serve_test.go @@ -3120,6 +3120,13 @@ func TestServerPlanResourceChange(t *testing.T) { }), resource: "test_attribute_plan_modifiers", resourceType: testServeResourceTypeAttributePlanModifiersType, + expectedDiags: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Warning diag", + Detail: "This is a warning", + }, + }, expectedPlannedState: tftypes.NewValue(testServeResourceTypeAttributePlanModifiersType, map[string]tftypes.Value{ "computed_string_no_modifiers": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), "name": tftypes.NewValue(tftypes.String, "name1"), @@ -3216,6 +3223,13 @@ func TestServerPlanResourceChange(t *testing.T) { }), resource: "test_attribute_plan_modifiers", resourceType: testServeResourceTypeAttributePlanModifiersType, + expectedDiags: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Warning diag", + Detail: "This is a warning", + }, + }, expectedPlannedState: tftypes.NewValue(testServeResourceTypeAttributePlanModifiersType, map[string]tftypes.Value{ "computed_string_no_modifiers": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), "name": tftypes.NewValue(tftypes.String, "name1"), @@ -3305,6 +3319,13 @@ func TestServerPlanResourceChange(t *testing.T) { }), resource: "test_attribute_plan_modifiers", resourceType: testServeResourceTypeAttributePlanModifiersType, + expectedDiags: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Warning diag", + Detail: "This is a warning", + }, + }, expectedPlannedState: tftypes.NewValue(testServeResourceTypeAttributePlanModifiersType, map[string]tftypes.Value{ "computed_string_no_modifiers": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), "name": tftypes.NewValue(tftypes.String, "name1"), @@ -3393,7 +3414,7 @@ func TestServerPlanResourceChange(t *testing.T) { "region": tftypes.NewValue(tftypes.String, "region1"), }), expectedPlannedState: tftypes.NewValue(testServeResourceTypeAttributePlanModifiersType, map[string]tftypes.Value{ - "computed_string_no_modifiers": tftypes.NewValue(tftypes.String, "statevalue"), + "computed_string_no_modifiers": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), "name": tftypes.NewValue(tftypes.String, "TESTDIAG"), "size": tftypes.NewValue(tftypes.Number, 3), "scratch_disk": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ @@ -3421,11 +3442,6 @@ func TestServerPlanResourceChange(t *testing.T) { Summary: "Warning diag", Detail: "This is a warning", }, - { - Severity: tfprotov6.DiagnosticSeverityError, - Summary: "Error diag", - Detail: "This is an error", - }, }, expectedRequiresReplace: []*tftypes.AttributePath{tftypes.NewAttributePath().WithAttributeName("scratch_disk").WithAttributeName("interface")}, }, @@ -3514,8 +3530,15 @@ func TestServerPlanResourceChange(t *testing.T) { }), "region": tftypes.NewValue(tftypes.String, "region1"), }), - resource: "test_attribute_plan_modifiers", - resourceType: testServeResourceTypeAttributePlanModifiersType, + resource: "test_attribute_plan_modifiers", + resourceType: testServeResourceTypeAttributePlanModifiersType, + expectedDiags: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Warning diag", + Detail: "This is a warning", + }, + }, expectedRequiresReplace: []*tftypes.AttributePath{tftypes.NewAttributePath().WithAttributeName("scratch_disk").WithAttributeName("interface")}, }, "attr_plan_modifiers_default_value_modifier": { @@ -3603,8 +3626,15 @@ func TestServerPlanResourceChange(t *testing.T) { }), "region": tftypes.NewValue(tftypes.String, "DEFAULTVALUE"), }), - resource: "test_attribute_plan_modifiers", - resourceType: testServeResourceTypeAttributePlanModifiersType, + resource: "test_attribute_plan_modifiers", + resourceType: testServeResourceTypeAttributePlanModifiersType, + expectedDiags: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Warning diag", + Detail: "This is a warning", + }, + }, expectedRequiresReplace: []*tftypes.AttributePath{tftypes.NewAttributePath().WithAttributeName("scratch_disk").WithAttributeName("interface")}, }, // TODO: Attribute plan modifiers should run before plan unknown marking. @@ -3675,6 +3705,13 @@ func TestServerPlanResourceChange(t *testing.T) { // }), // "region": tftypes.NewValue(tftypes.String, "DEFAULTVALUE"), // }), + // expectedDiags: []*tfprotov6.Diagnostic{ + // { + // Severity: tfprotov6.DiagnosticSeverityWarning, + // Summary: "Warning diag", + // Detail: "This is a warning", + // }, + // }, // expectedPlannedState: tftypes.NewValue(testServeResourceTypeAttributePlanModifiersType, map[string]tftypes.Value{ // "computed_string_no_modifiers": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), // "name": tftypes.NewValue(tftypes.String, "MODIFIED_TWO"), @@ -3783,8 +3820,15 @@ func TestServerPlanResourceChange(t *testing.T) { }), "region": tftypes.NewValue(tftypes.String, "region1"), }), - resource: "test_attribute_plan_modifiers", - resourceType: testServeResourceTypeAttributePlanModifiersType, + resource: "test_attribute_plan_modifiers", + resourceType: testServeResourceTypeAttributePlanModifiersType, + expectedDiags: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "Warning diag", + Detail: "This is a warning", + }, + }, expectedRequiresReplace: []*tftypes.AttributePath{tftypes.NewAttributePath().WithAttributeName("scratch_disk").WithAttributeName("interface")}, }, }