diff --git a/.changes/unreleased/ENHANCEMENTS-20230207-090911.yaml b/.changes/unreleased/ENHANCEMENTS-20230207-090911.yaml new file mode 100644 index 0000000..e675bf4 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20230207-090911.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: 'tf5muxserver+tf6muxserver: Support Terraform 1.3+ PlanResourceChange on destroy + for underlying servers which enable the capability, such as terraform-plugin-framework' +time: 2023-02-07T09:09:11.640268-05:00 +custom: + Issue: "133" diff --git a/internal/tf5dynamicvalue/doc.go b/internal/tf5dynamicvalue/doc.go new file mode 100644 index 0000000..f5d249e --- /dev/null +++ b/internal/tf5dynamicvalue/doc.go @@ -0,0 +1,2 @@ +// Package tf5dynamicvalue contains shared *tfprotov5.DynamicValue functions. +package tf5dynamicvalue diff --git a/tf5muxserver/dynamic_value_equality.go b/internal/tf5dynamicvalue/equals.go similarity index 76% rename from tf5muxserver/dynamic_value_equality.go rename to internal/tf5dynamicvalue/equals.go index 0fc9dd6..846e632 100644 --- a/tf5muxserver/dynamic_value_equality.go +++ b/internal/tf5dynamicvalue/equals.go @@ -1,4 +1,4 @@ -package tf5muxserver +package tf5dynamicvalue import ( "fmt" @@ -7,8 +7,8 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) -// dynamicValueEquals performs equality checking of DynamicValue. -func dynamicValueEquals(schemaType tftypes.Type, i *tfprotov5.DynamicValue, j *tfprotov5.DynamicValue) (bool, error) { +// Equals performs equality checking of two given *tfprotov5.DynamicValue. +func Equals(schemaType tftypes.Type, i *tfprotov5.DynamicValue, j *tfprotov5.DynamicValue) (bool, error) { if i == nil { return j == nil, nil } diff --git a/internal/tf5dynamicvalue/equals_test.go b/internal/tf5dynamicvalue/equals_test.go new file mode 100644 index 0000000..5d264f8 --- /dev/null +++ b/internal/tf5dynamicvalue/equals_test.go @@ -0,0 +1,236 @@ +package tf5dynamicvalue_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-mux/internal/tf5dynamicvalue" +) + +func TestEquals(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schemaType tftypes.Type + dynamicValue1 *tfprotov5.DynamicValue + dynamicValue2 *tfprotov5.DynamicValue + expected bool + expectedError error + }{ + "all-missing": { + schemaType: nil, + dynamicValue1: nil, + dynamicValue2: nil, + expected: true, + }, + "first-missing": { + schemaType: nil, + dynamicValue1: nil, + dynamicValue2: &tfprotov5.DynamicValue{}, + expected: false, + }, + "second-missing": { + schemaType: nil, + dynamicValue1: &tfprotov5.DynamicValue{}, + dynamicValue2: nil, + expected: false, + }, + "missing-type": { + schemaType: nil, + dynamicValue1: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + dynamicValue2: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: missing Type"), + }, + "mismatched-type": { + schemaType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_bool_attribute": tftypes.Bool, + }, + }, + dynamicValue1: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + dynamicValue2: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: unknown attribute \"test_string_attribute\""), + }, + "String-different-value": { + schemaType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + dynamicValue1: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-1"), + }, + ), + ), + dynamicValue2: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-2"), + }, + ), + ), + expected: false, + }, + "String-equal-value": { + schemaType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + dynamicValue1: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + dynamicValue2: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := tf5dynamicvalue.Equals(testCase.schemaType, testCase.dynamicValue1, testCase.dynamicValue2) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, wanted err: %s", testCase.expectedError) + } + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} diff --git a/internal/tf5dynamicvalue/is_null.go b/internal/tf5dynamicvalue/is_null.go new file mode 100644 index 0000000..a6f2d01 --- /dev/null +++ b/internal/tf5dynamicvalue/is_null.go @@ -0,0 +1,28 @@ +package tf5dynamicvalue + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// IsNull returns true if the given *tfprotov5.DynamicValue is nil or +// represents a null value. +func IsNull(schema *tfprotov5.Schema, dynamicValue *tfprotov5.DynamicValue) (bool, error) { + if dynamicValue == nil { + return true, nil + } + + // Panic prevention + if schema == nil { + return false, fmt.Errorf("unable to unmarshal DynamicValue: missing Type") + } + + tfValue, err := dynamicValue.Unmarshal(schema.ValueType()) + + if err != nil { + return false, fmt.Errorf("unable to unmarshal DynamicValue: %w", err) + } + + return tfValue.IsNull(), nil +} diff --git a/internal/tf5dynamicvalue/is_null_test.go b/internal/tf5dynamicvalue/is_null_test.go new file mode 100644 index 0000000..f3809da --- /dev/null +++ b/internal/tf5dynamicvalue/is_null_test.go @@ -0,0 +1,151 @@ +package tf5dynamicvalue_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-mux/internal/tf5dynamicvalue" +) + +func TestIsNull(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema *tfprotov5.Schema + dynamicValue *tfprotov5.DynamicValue + expected bool + expectedError error + }{ + "nil-dynamic-value": { + schema: nil, + dynamicValue: nil, + expected: true, + }, + "nil-schema": { + schema: nil, + dynamicValue: &tfprotov5.DynamicValue{}, + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: missing Type"), + }, + "NewDynamicValue-error": { + schema: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_bool_attribute", // intentionally different + Type: tftypes.Bool, // intentionally different + }, + }, + }, + }, + dynamicValue: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: unknown attribute \"test_string_attribute\""), + }, + "null": { + schema: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + dynamicValue: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + nil, + ), + ), + expected: true, + }, + "known": { + schema: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + dynamicValue: tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := tf5dynamicvalue.IsNull(testCase.schema, testCase.dynamicValue) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, wanted err: %s", testCase.expectedError) + } + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} diff --git a/internal/tf5dynamicvalue/must.go b/internal/tf5dynamicvalue/must.go new file mode 100644 index 0000000..0fd5a6b --- /dev/null +++ b/internal/tf5dynamicvalue/must.go @@ -0,0 +1,22 @@ +package tf5dynamicvalue + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// Must creates a *tfprotov5.DynamicValue or panics. This is intended only for +// simplifying testing code. +// +// The tftypes.Type parameter is separate to enable DynamicPsuedoType testing. +func Must(typ tftypes.Type, value tftypes.Value) *tfprotov5.DynamicValue { + dynamicValue, err := tfprotov5.NewDynamicValue(typ, value) + + if err != nil { + panic(fmt.Sprintf("unable to create DynamicValue: %s", err.Error())) + } + + return &dynamicValue +} diff --git a/internal/tf5testserver/tf5testserver.go b/internal/tf5testserver/tf5testserver.go index e6002b9..4a42b95 100644 --- a/internal/tf5testserver/tf5testserver.go +++ b/internal/tf5testserver/tf5testserver.go @@ -13,6 +13,7 @@ type TestServer struct { ProviderMetaSchema *tfprotov5.Schema ProviderSchema *tfprotov5.Schema ResourceSchemas map[string]*tfprotov5.Schema + ServerCapabilities *tfprotov5.ServerCapabilities ApplyResourceChangeCalled map[string]bool @@ -71,10 +72,11 @@ func (s *TestServer) GetProviderSchema(_ context.Context, _ *tfprotov5.GetProvid } return &tfprotov5.GetProviderSchemaResponse{ - Provider: s.ProviderSchema, - ProviderMeta: s.ProviderMetaSchema, - ResourceSchemas: s.ResourceSchemas, - DataSourceSchemas: s.DataSourceSchemas, + Provider: s.ProviderSchema, + ProviderMeta: s.ProviderMetaSchema, + ResourceSchemas: s.ResourceSchemas, + DataSourceSchemas: s.DataSourceSchemas, + ServerCapabilities: s.ServerCapabilities, }, nil } diff --git a/internal/tf6dynamicvalue/doc.go b/internal/tf6dynamicvalue/doc.go new file mode 100644 index 0000000..eacba2e --- /dev/null +++ b/internal/tf6dynamicvalue/doc.go @@ -0,0 +1,2 @@ +// Package tf6dynamicvalue contains shared *tfprotov6.DynamicValue functions. +package tf6dynamicvalue diff --git a/tf6muxserver/dynamic_value_equality.go b/internal/tf6dynamicvalue/equals.go similarity index 76% rename from tf6muxserver/dynamic_value_equality.go rename to internal/tf6dynamicvalue/equals.go index 7abd72b..7ac914c 100644 --- a/tf6muxserver/dynamic_value_equality.go +++ b/internal/tf6dynamicvalue/equals.go @@ -1,4 +1,4 @@ -package tf6muxserver +package tf6dynamicvalue import ( "fmt" @@ -7,8 +7,8 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) -// dynamicValueEquals performs equality checking of DynamicValue. -func dynamicValueEquals(schemaType tftypes.Type, i *tfprotov6.DynamicValue, j *tfprotov6.DynamicValue) (bool, error) { +// Equals performs equality checking of two given *tfprotov6.DynamicValue. +func Equals(schemaType tftypes.Type, i *tfprotov6.DynamicValue, j *tfprotov6.DynamicValue) (bool, error) { if i == nil { return j == nil, nil } diff --git a/internal/tf6dynamicvalue/equals_test.go b/internal/tf6dynamicvalue/equals_test.go new file mode 100644 index 0000000..7c10c83 --- /dev/null +++ b/internal/tf6dynamicvalue/equals_test.go @@ -0,0 +1,236 @@ +package tf6dynamicvalue_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-mux/internal/tf6dynamicvalue" +) + +func TestEquals(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schemaType tftypes.Type + dynamicValue1 *tfprotov6.DynamicValue + dynamicValue2 *tfprotov6.DynamicValue + expected bool + expectedError error + }{ + "all-missing": { + schemaType: nil, + dynamicValue1: nil, + dynamicValue2: nil, + expected: true, + }, + "first-missing": { + schemaType: nil, + dynamicValue1: nil, + dynamicValue2: &tfprotov6.DynamicValue{}, + expected: false, + }, + "second-missing": { + schemaType: nil, + dynamicValue1: &tfprotov6.DynamicValue{}, + dynamicValue2: nil, + expected: false, + }, + "missing-type": { + schemaType: nil, + dynamicValue1: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + dynamicValue2: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: missing Type"), + }, + "mismatched-type": { + schemaType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_bool_attribute": tftypes.Bool, + }, + }, + dynamicValue1: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + dynamicValue2: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: unknown attribute \"test_string_attribute\""), + }, + "String-different-value": { + schemaType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + dynamicValue1: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-1"), + }, + ), + ), + dynamicValue2: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-2"), + }, + ), + ), + expected: false, + }, + "String-equal-value": { + schemaType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + dynamicValue1: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + dynamicValue2: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := tf6dynamicvalue.Equals(testCase.schemaType, testCase.dynamicValue1, testCase.dynamicValue2) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, wanted err: %s", testCase.expectedError) + } + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} diff --git a/internal/tf6dynamicvalue/is_null.go b/internal/tf6dynamicvalue/is_null.go new file mode 100644 index 0000000..bed06cf --- /dev/null +++ b/internal/tf6dynamicvalue/is_null.go @@ -0,0 +1,28 @@ +package tf6dynamicvalue + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// IsNull returns true if the given *tfprotov6.DynamicValue is nil or +// represents a null value. +func IsNull(schema *tfprotov6.Schema, dynamicValue *tfprotov6.DynamicValue) (bool, error) { + if dynamicValue == nil { + return true, nil + } + + // Panic prevention + if schema == nil { + return false, fmt.Errorf("unable to unmarshal DynamicValue: missing Type") + } + + tfValue, err := dynamicValue.Unmarshal(schema.ValueType()) + + if err != nil { + return false, fmt.Errorf("unable to unmarshal DynamicValue: %w", err) + } + + return tfValue.IsNull(), nil +} diff --git a/internal/tf6dynamicvalue/is_null_test.go b/internal/tf6dynamicvalue/is_null_test.go new file mode 100644 index 0000000..d24c224 --- /dev/null +++ b/internal/tf6dynamicvalue/is_null_test.go @@ -0,0 +1,151 @@ +package tf6dynamicvalue_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-mux/internal/tf6dynamicvalue" +) + +func TestIsNull(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema *tfprotov6.Schema + dynamicValue *tfprotov6.DynamicValue + expected bool + expectedError error + }{ + "nil-dynamic-value": { + schema: nil, + dynamicValue: nil, + expected: true, + }, + "nil-schema": { + schema: nil, + dynamicValue: &tfprotov6.DynamicValue{}, + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: missing Type"), + }, + "NewDynamicValue-error": { + schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_bool_attribute", // intentionally different + Type: tftypes.Bool, // intentionally different + }, + }, + }, + }, + dynamicValue: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + expectedError: fmt.Errorf("unable to unmarshal DynamicValue: unknown attribute \"test_string_attribute\""), + }, + "null": { + schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + dynamicValue: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + nil, + ), + ), + expected: true, + }, + "known": { + schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + dynamicValue: tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := tf6dynamicvalue.IsNull(testCase.schema, testCase.dynamicValue) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, wanted err: %s", testCase.expectedError) + } + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} diff --git a/internal/tf6dynamicvalue/must.go b/internal/tf6dynamicvalue/must.go new file mode 100644 index 0000000..907d1c8 --- /dev/null +++ b/internal/tf6dynamicvalue/must.go @@ -0,0 +1,22 @@ +package tf6dynamicvalue + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// Must creates a *tfprotov6.DynamicValue or panics. This is intended only for +// simplifying testing code. +// +// The tftypes.Type parameter is separate to enable DynamicPsuedoType testing. +func Must(typ tftypes.Type, value tftypes.Value) *tfprotov6.DynamicValue { + dynamicValue, err := tfprotov6.NewDynamicValue(typ, value) + + if err != nil { + panic(fmt.Sprintf("unable to create DynamicValue: %s", err.Error())) + } + + return &dynamicValue +} diff --git a/internal/tf6testserver/tf6testserver.go b/internal/tf6testserver/tf6testserver.go index e8d3f98..65b0df7 100644 --- a/internal/tf6testserver/tf6testserver.go +++ b/internal/tf6testserver/tf6testserver.go @@ -13,6 +13,7 @@ type TestServer struct { ProviderMetaSchema *tfprotov6.Schema ProviderSchema *tfprotov6.Schema ResourceSchemas map[string]*tfprotov6.Schema + ServerCapabilities *tfprotov6.ServerCapabilities ApplyResourceChangeCalled map[string]bool @@ -71,10 +72,11 @@ func (s *TestServer) GetProviderSchema(_ context.Context, _ *tfprotov6.GetProvid } return &tfprotov6.GetProviderSchemaResponse{ - Provider: s.ProviderSchema, - ProviderMeta: s.ProviderMetaSchema, - ResourceSchemas: s.ResourceSchemas, - DataSourceSchemas: s.DataSourceSchemas, + Provider: s.ProviderSchema, + ProviderMeta: s.ProviderMetaSchema, + ResourceSchemas: s.ResourceSchemas, + DataSourceSchemas: s.DataSourceSchemas, + ServerCapabilities: s.ServerCapabilities, }, nil } diff --git a/tf5muxserver/dynamic_value_equality_test.go b/tf5muxserver/dynamic_value_equality_test.go deleted file mode 100644 index 0e967fd..0000000 --- a/tf5muxserver/dynamic_value_equality_test.go +++ /dev/null @@ -1,283 +0,0 @@ -package tf5muxserver - -import ( - "fmt" - "strings" - "testing" - - "github.com/hashicorp/terraform-plugin-go/tfprotov5" - "github.com/hashicorp/terraform-plugin-go/tftypes" -) - -func TestDynamicValueEquals(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - schemaType tftypes.Type - dynamicValue1 func() (*tfprotov5.DynamicValue, error) - dynamicValue2 func() (*tfprotov5.DynamicValue, error) - expected bool - expectedError error - }{ - "all-missing": { - schemaType: nil, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - return nil, nil - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - return nil, nil - }, - expected: true, - }, - "first-missing": { - schemaType: nil, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - return nil, nil - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - return &tfprotov5.DynamicValue{}, nil - }, - expected: false, - }, - "second-missing": { - schemaType: nil, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - return &tfprotov5.DynamicValue{}, nil - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - return nil, nil - }, - expected: false, - }, - "missing-type": { - schemaType: nil, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - expected: false, - expectedError: fmt.Errorf("unable to unmarshal DynamicValue: missing Type"), - }, - "mismatched-type": { - schemaType: tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_bool_attribute": tftypes.Bool, - }, - }, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - expected: false, - expectedError: fmt.Errorf("unable to unmarshal DynamicValue: unknown attribute \"test_string_attribute\""), - }, - "String-different-value": { - schemaType: tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-1"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-2"), - }, - ), - ) - return &dv, err - }, - expected: false, - }, - "String-equal-value": { - schemaType: tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - dynamicValue1: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov5.DynamicValue, error) { - dv, err := tfprotov5.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - expected: true, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - dynamicValue1, err := testCase.dynamicValue1() - - if err != nil { - t.Fatalf("unable to create first DynamicValue: %s", err) - } - - dynamicValue2, err := testCase.dynamicValue2() - - if err != nil { - t.Fatalf("unable to create second DynamicValue: %s", err) - } - - got, err := dynamicValueEquals(testCase.schemaType, dynamicValue1, dynamicValue2) - - if err != nil { - if testCase.expectedError == nil { - t.Fatalf("wanted no error, got error: %s", err) - } - - if !strings.Contains(err.Error(), testCase.expectedError.Error()) { - t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) - } - } - - if err == nil && testCase.expectedError != nil { - t.Fatalf("got no error, wanted err: %s", testCase.expectedError) - } - - if got != testCase.expected { - t.Errorf("expected %t, got %t", testCase.expected, got) - } - }) - } -} diff --git a/tf5muxserver/mux_server.go b/tf5muxserver/mux_server.go index 38ee38b..6057fe8 100644 --- a/tf5muxserver/mux_server.go +++ b/tf5muxserver/mux_server.go @@ -35,11 +35,12 @@ type muxServer struct { serverProviderMetaSchemaDifferences []string serverResourceSchemaDuplicates []string - // Schemas are cached during server creation - dataSourceSchemas map[string]*tfprotov5.Schema - providerMetaSchema *tfprotov5.Schema - providerSchema *tfprotov5.Schema - resourceSchemas map[string]*tfprotov5.Schema + // Server capabilities and schemas are cached during server creation + dataSourceSchemas map[string]*tfprotov5.Schema + providerMetaSchema *tfprotov5.Schema + providerSchema *tfprotov5.Schema + resourceCapabilities map[string]*tfprotov5.ServerCapabilities + resourceSchemas map[string]*tfprotov5.Schema } // ProviderServer is a function compatible with tf6server.Serve. @@ -61,10 +62,11 @@ func (s muxServer) ProviderServer() tfprotov5.ProviderServer { func NewMuxServer(ctx context.Context, servers ...func() tfprotov5.ProviderServer) (muxServer, error) { ctx = logging.InitContext(ctx) result := muxServer{ - dataSources: make(map[string]tfprotov5.ProviderServer), - dataSourceSchemas: make(map[string]*tfprotov5.Schema), - resources: make(map[string]tfprotov5.ProviderServer), - resourceSchemas: make(map[string]*tfprotov5.Schema), + dataSources: make(map[string]tfprotov5.ProviderServer), + dataSourceSchemas: make(map[string]*tfprotov5.Schema), + resources: make(map[string]tfprotov5.ProviderServer), + resourceCapabilities: make(map[string]*tfprotov5.ServerCapabilities), + resourceSchemas: make(map[string]*tfprotov5.Schema), } for _, serverFunc := range servers { @@ -112,6 +114,8 @@ func NewMuxServer(ctx context.Context, servers ...func() tfprotov5.ProviderServe result.resources[resourceType] = server result.resourceSchemas[resourceType] = schema } + + result.resourceCapabilities[resourceType] = resp.ServerCapabilities } for dataSourceType, schema := range resp.DataSourceSchemas { diff --git a/tf5muxserver/mux_server_GetProviderSchema.go b/tf5muxserver/mux_server_GetProviderSchema.go index 4b60338..6455178 100644 --- a/tf5muxserver/mux_server_GetProviderSchema.go +++ b/tf5muxserver/mux_server_GetProviderSchema.go @@ -22,6 +22,13 @@ func (s muxServer) GetProviderSchema(ctx context.Context, req *tfprotov5.GetProv ResourceSchemas: s.resourceSchemas, DataSourceSchemas: s.dataSourceSchemas, ProviderMeta: s.providerMetaSchema, + + // Always announce all ServerCapabilities. Individual capabilities are + // handled in their respective RPCs to protect downstream servers if + // they are not compatible with a capability. + ServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, } for _, diff := range s.serverProviderSchemaDifferences { diff --git a/tf5muxserver/mux_server_GetProviderSchema_test.go b/tf5muxserver/mux_server_GetProviderSchema_test.go index e2c6332..780dcae 100644 --- a/tf5muxserver/mux_server_GetProviderSchema_test.go +++ b/tf5muxserver/mux_server_GetProviderSchema_test.go @@ -21,6 +21,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { expectedProviderSchema *tfprotov5.Schema expectedProviderMetaSchema *tfprotov5.Schema expectedResourceSchemas map[string]*tfprotov5.Schema + expectedServerCapabilities *tfprotov5.ServerCapabilities }{ "combined": { servers: []func() tfprotov5.ProviderServer{ @@ -416,6 +417,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, }, "duplicate-data-source-type": { servers: []func() tfprotov5.ProviderServer{ @@ -443,6 +447,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, expectedResourceSchemas: map[string]*tfprotov5.Schema{}, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, }, "duplicate-resource-type": { servers: []func() tfprotov5.ProviderServer{ @@ -470,6 +477,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { expectedResourceSchemas: map[string]*tfprotov5.Schema{ "test_foo": {}, }, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, }, "provider-mismatch": { servers: []func() tfprotov5.ProviderServer{ @@ -545,6 +555,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, expectedResourceSchemas: map[string]*tfprotov5.Schema{}, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, }, "provider-meta-mismatch": { servers: []func() tfprotov5.ProviderServer{ @@ -620,6 +633,34 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, expectedResourceSchemas: map[string]*tfprotov5.Schema{}, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, + }, + "server-capabilities": { + servers: []func() tfprotov5.ProviderServer{ + (&tf5testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_with_server_capabilities": {}, + }, + ServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, + }).ProviderServer, + (&tf5testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_without_server_capabilities": {}, + }, + }).ProviderServer, + }, + expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, + expectedResourceSchemas: map[string]*tfprotov5.Schema{ + "test_with_server_capabilities": {}, + "test_without_server_capabilities": {}, + }, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, }, } diff --git a/tf5muxserver/mux_server_PlanResourceChange.go b/tf5muxserver/mux_server_PlanResourceChange.go index 1ce933e..7d90dbd 100644 --- a/tf5muxserver/mux_server_PlanResourceChange.go +++ b/tf5muxserver/mux_server_PlanResourceChange.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-mux/internal/logging" + "github.com/hashicorp/terraform-plugin-mux/internal/tf5dynamicvalue" ) // PlanResourceChange calls the PlanResourceChange method, passing `req`, on @@ -22,6 +23,34 @@ func (s muxServer) PlanResourceChange(ctx context.Context, req *tfprotov5.PlanRe } ctx = logging.Tfprotov5ProviderServerContext(ctx, server) + + // Prevent ServerCapabilities.PlanDestroy from sending destroy plans to + // servers which do not enable the capability. + resourceCapabilities := s.resourceCapabilities[req.TypeName] + + if resourceCapabilities == nil || !resourceCapabilities.PlanDestroy { + resourceSchema := s.resourceSchemas[req.TypeName] + + isDestroyPlan, err := tf5dynamicvalue.IsNull(resourceSchema, req.ProposedNewState) + + if err != nil { + return nil, fmt.Errorf("unable to determine if request is destroy plan: %w", err) + } + + if isDestroyPlan { + logging.MuxTrace(ctx, "server does not enable destroy plans, returning without calling downstream server") + + resp := &tfprotov5.PlanResourceChangeResponse{ + // Presumably, we must preserve any prior private state so it + // is still available during ApplyResourceChange. + PlannedPrivate: req.PriorPrivate, + PlannedState: req.ProposedNewState, + } + + return resp, nil + } + } + logging.MuxTrace(ctx, "calling downstream server") return server.PlanResourceChange(ctx, req) diff --git a/tf5muxserver/mux_server_PlanResourceChange_test.go b/tf5muxserver/mux_server_PlanResourceChange_test.go index b95f7b9..02ea243 100644 --- a/tf5muxserver/mux_server_PlanResourceChange_test.go +++ b/tf5muxserver/mux_server_PlanResourceChange_test.go @@ -5,26 +5,65 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-mux/internal/tf5dynamicvalue" "github.com/hashicorp/terraform-plugin-mux/internal/tf5testserver" "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" ) -func TestMuxServerPlanResourceChange(t *testing.T) { +func TestMuxServerPlanResourceChange_Routing(t *testing.T) { t.Parallel() ctx := context.Background() testServer1 := &tf5testserver.TestServer{ ResourceSchemas: map[string]*tfprotov5.Schema{ - "test_resource_server1": {}, + "test_resource_server1": { + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, }, } testServer2 := &tf5testserver.TestServer{ ResourceSchemas: map[string]*tfprotov5.Schema{ - "test_resource_server2": {}, + "test_resource_server2": { + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, }, } + testProposedNewState := tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + // intentionally set for create/update plan + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ) + servers := []func() tfprotov5.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} muxServer, err := tf5muxserver.NewMuxServer(ctx, servers...) @@ -33,7 +72,8 @@ func TestMuxServerPlanResourceChange(t *testing.T) { } _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov5.PlanResourceChangeRequest{ - TypeName: "test_resource_server1", + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server1", }) if err != nil { @@ -49,7 +89,8 @@ func TestMuxServerPlanResourceChange(t *testing.T) { } _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov5.PlanResourceChangeRequest{ - TypeName: "test_resource_server2", + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server2", }) if err != nil { @@ -64,3 +105,100 @@ func TestMuxServerPlanResourceChange(t *testing.T) { t.Errorf("expected test_resource_server2 PlanResourceChange to be called on server2") } } + +func TestMuxServerPlanResourceChange_ServerCapabilities_PlanDestroy(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + testServer1 := &tf5testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_resource_server1": { + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + }, + ServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, + } + testServer2 := &tf5testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_resource_server2": { + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + }, + // Intentionally no ServerCapabilities on this server + } + + testProposedNewState := tf5dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + nil, // intentionally null for destroy plan + ), + ) + + servers := []func() tfprotov5.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} + muxServer, err := tf5muxserver.NewMuxServer(ctx, servers...) + + if err != nil { + t.Fatalf("unexpected error setting up factory: %s", err) + } + + _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov5.PlanResourceChangeRequest{ + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server1", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !testServer1.PlanResourceChangeCalled["test_resource_server1"] { + t.Errorf("expected test_resource_server1 PlanResourceChange to be called on server1") + } + + if testServer2.PlanResourceChangeCalled["test_resource_server1"] { + t.Errorf("unexpected test_resource_server1 PlanResourceChange called on server2") + } + + _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov5.PlanResourceChangeRequest{ + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server2", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if testServer1.PlanResourceChangeCalled["test_resource_server2"] { + t.Errorf("unexpected test_resource_server2 PlanResourceChange called on server1") + } + + // Server does not enable ServerCapabilities.PlanDestroy + if testServer2.PlanResourceChangeCalled["test_resource_server2"] { + t.Errorf("unexpected test_resource_server2 PlanResourceChange called on server2") + } +} diff --git a/tf5muxserver/mux_server_PrepareProviderConfig.go b/tf5muxserver/mux_server_PrepareProviderConfig.go index 22695c4..9a0752e 100644 --- a/tf5muxserver/mux_server_PrepareProviderConfig.go +++ b/tf5muxserver/mux_server_PrepareProviderConfig.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-mux/internal/logging" + "github.com/hashicorp/terraform-plugin-mux/internal/tf5dynamicvalue" ) // PrepareProviderConfig calls the PrepareProviderConfig method on each server @@ -48,7 +49,7 @@ func (s muxServer) PrepareProviderConfig(ctx context.Context, req *tfprotov5.Pre continue } - equal, err := dynamicValueEquals(s.providerSchema.ValueType(), res.PreparedConfig, resp.PreparedConfig) + equal, err := tf5dynamicvalue.Equals(s.providerSchema.ValueType(), res.PreparedConfig, resp.PreparedConfig) if err != nil { return nil, fmt.Errorf("unable to compare PrepareProviderConfig PreparedConfig responses: %w", err) diff --git a/tf5muxserver/mux_server_test.go b/tf5muxserver/mux_server_test.go index fbd9c96..e676f3c 100644 --- a/tf5muxserver/mux_server_test.go +++ b/tf5muxserver/mux_server_test.go @@ -594,6 +594,24 @@ func TestNewMuxServer(t *testing.T) { }).ProviderServer, }, }, + "server-capabilities": { + servers: []func() tfprotov5.ProviderServer{ + (&tf5testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_with_server_capabilities": {}, + }, + ServerCapabilities: &tfprotov5.ServerCapabilities{ + PlanDestroy: true, + }, + }).ProviderServer, + (&tf5testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_without_server_capabilities": {}, + }, + }).ProviderServer, + }, + expectedError: nil, + }, } for name, testCase := range testCases { diff --git a/tf6muxserver/dynamic_value_equality_test.go b/tf6muxserver/dynamic_value_equality_test.go deleted file mode 100644 index bd3dc5f..0000000 --- a/tf6muxserver/dynamic_value_equality_test.go +++ /dev/null @@ -1,283 +0,0 @@ -package tf6muxserver - -import ( - "fmt" - "strings" - "testing" - - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tftypes" -) - -func TestDynamicValueEquals(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - schemaType tftypes.Type - dynamicValue1 func() (*tfprotov6.DynamicValue, error) - dynamicValue2 func() (*tfprotov6.DynamicValue, error) - expected bool - expectedError error - }{ - "all-missing": { - schemaType: nil, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - return nil, nil - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - return nil, nil - }, - expected: true, - }, - "first-missing": { - schemaType: nil, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - return nil, nil - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - return &tfprotov6.DynamicValue{}, nil - }, - expected: false, - }, - "second-missing": { - schemaType: nil, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - return &tfprotov6.DynamicValue{}, nil - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - return nil, nil - }, - expected: false, - }, - "missing-type": { - schemaType: nil, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - expected: false, - expectedError: fmt.Errorf("unable to unmarshal DynamicValue: missing Type"), - }, - "mismatched-type": { - schemaType: tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_bool_attribute": tftypes.Bool, - }, - }, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - expected: false, - expectedError: fmt.Errorf("unable to unmarshal DynamicValue: unknown attribute \"test_string_attribute\""), - }, - "String-different-value": { - schemaType: tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-1"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value-2"), - }, - ), - ) - return &dv, err - }, - expected: false, - }, - "String-equal-value": { - schemaType: tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - dynamicValue1: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - dynamicValue2: func() (*tfprotov6.DynamicValue, error) { - dv, err := tfprotov6.NewDynamicValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - tftypes.NewValue( - tftypes.Object{ - AttributeTypes: map[string]tftypes.Type{ - "test_string_attribute": tftypes.String, - }, - }, - map[string]tftypes.Value{ - "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), - }, - ), - ) - return &dv, err - }, - expected: true, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - dynamicValue1, err := testCase.dynamicValue1() - - if err != nil { - t.Fatalf("unable to create first DynamicValue: %s", err) - } - - dynamicValue2, err := testCase.dynamicValue2() - - if err != nil { - t.Fatalf("unable to create second DynamicValue: %s", err) - } - - got, err := dynamicValueEquals(testCase.schemaType, dynamicValue1, dynamicValue2) - - if err != nil { - if testCase.expectedError == nil { - t.Fatalf("wanted no error, got error: %s", err) - } - - if !strings.Contains(err.Error(), testCase.expectedError.Error()) { - t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) - } - } - - if err == nil && testCase.expectedError != nil { - t.Fatalf("got no error, wanted err: %s", testCase.expectedError) - } - - if got != testCase.expected { - t.Errorf("expected %t, got %t", testCase.expected, got) - } - }) - } -} diff --git a/tf6muxserver/mux_server.go b/tf6muxserver/mux_server.go index ff3d037..69f63b9 100644 --- a/tf6muxserver/mux_server.go +++ b/tf6muxserver/mux_server.go @@ -36,10 +36,11 @@ type muxServer struct { serverResourceSchemaDuplicates []string // Schemas are cached during server creation - dataSourceSchemas map[string]*tfprotov6.Schema - providerMetaSchema *tfprotov6.Schema - providerSchema *tfprotov6.Schema - resourceSchemas map[string]*tfprotov6.Schema + dataSourceSchemas map[string]*tfprotov6.Schema + providerMetaSchema *tfprotov6.Schema + providerSchema *tfprotov6.Schema + resourceCapabilities map[string]*tfprotov6.ServerCapabilities + resourceSchemas map[string]*tfprotov6.Schema } // ProviderServer is a function compatible with tf6server.Serve. @@ -61,10 +62,11 @@ func (s muxServer) ProviderServer() tfprotov6.ProviderServer { func NewMuxServer(ctx context.Context, servers ...func() tfprotov6.ProviderServer) (muxServer, error) { ctx = logging.InitContext(ctx) result := muxServer{ - dataSources: make(map[string]tfprotov6.ProviderServer), - dataSourceSchemas: make(map[string]*tfprotov6.Schema), - resources: make(map[string]tfprotov6.ProviderServer), - resourceSchemas: make(map[string]*tfprotov6.Schema), + dataSources: make(map[string]tfprotov6.ProviderServer), + dataSourceSchemas: make(map[string]*tfprotov6.Schema), + resources: make(map[string]tfprotov6.ProviderServer), + resourceCapabilities: make(map[string]*tfprotov6.ServerCapabilities), + resourceSchemas: make(map[string]*tfprotov6.Schema), } for _, serverFunc := range servers { @@ -112,6 +114,8 @@ func NewMuxServer(ctx context.Context, servers ...func() tfprotov6.ProviderServe result.resources[resourceType] = server result.resourceSchemas[resourceType] = schema } + + result.resourceCapabilities[resourceType] = resp.ServerCapabilities } for dataSourceType, schema := range resp.DataSourceSchemas { diff --git a/tf6muxserver/mux_server_GetProviderSchema.go b/tf6muxserver/mux_server_GetProviderSchema.go index cde9c6a..7d1bcdb 100644 --- a/tf6muxserver/mux_server_GetProviderSchema.go +++ b/tf6muxserver/mux_server_GetProviderSchema.go @@ -22,6 +22,13 @@ func (s muxServer) GetProviderSchema(ctx context.Context, req *tfprotov6.GetProv ResourceSchemas: s.resourceSchemas, DataSourceSchemas: s.dataSourceSchemas, ProviderMeta: s.providerMetaSchema, + + // Always announce all ServerCapabilities. Individual capabilities are + // handled in their respective RPCs to protect downstream servers if + // they are not compatible with a capability. + ServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, } for _, diff := range s.serverProviderSchemaDifferences { diff --git a/tf6muxserver/mux_server_GetProviderSchema_test.go b/tf6muxserver/mux_server_GetProviderSchema_test.go index d1ef2f7..412909e 100644 --- a/tf6muxserver/mux_server_GetProviderSchema_test.go +++ b/tf6muxserver/mux_server_GetProviderSchema_test.go @@ -21,6 +21,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { expectedProviderSchema *tfprotov6.Schema expectedProviderMetaSchema *tfprotov6.Schema expectedResourceSchemas map[string]*tfprotov6.Schema + expectedServerCapabilities *tfprotov6.ServerCapabilities }{ "combined": { servers: []func() tfprotov6.ProviderServer{ @@ -416,6 +417,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, }, "duplicate-data-source-type": { servers: []func() tfprotov6.ProviderServer{ @@ -443,6 +447,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, expectedResourceSchemas: map[string]*tfprotov6.Schema{}, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, }, "duplicate-resource-type": { servers: []func() tfprotov6.ProviderServer{ @@ -470,6 +477,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { expectedResourceSchemas: map[string]*tfprotov6.Schema{ "test_foo": {}, }, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, }, "provider-mismatch": { servers: []func() tfprotov6.ProviderServer{ @@ -545,6 +555,9 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, expectedResourceSchemas: map[string]*tfprotov6.Schema{}, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, }, "provider-meta-mismatch": { servers: []func() tfprotov6.ProviderServer{ @@ -620,6 +633,34 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, expectedResourceSchemas: map[string]*tfprotov6.Schema{}, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, + }, + "server-capabilities": { + servers: []func() tfprotov6.ProviderServer{ + (&tf6testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_with_server_capabilities": {}, + }, + ServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, + }).ProviderServer, + (&tf6testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_without_server_capabilities": {}, + }, + }).ProviderServer, + }, + expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, + expectedResourceSchemas: map[string]*tfprotov6.Schema{ + "test_with_server_capabilities": {}, + "test_without_server_capabilities": {}, + }, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, }, } diff --git a/tf6muxserver/mux_server_PlanResourceChange.go b/tf6muxserver/mux_server_PlanResourceChange.go index 3e29edb..222de35 100644 --- a/tf6muxserver/mux_server_PlanResourceChange.go +++ b/tf6muxserver/mux_server_PlanResourceChange.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-mux/internal/logging" + "github.com/hashicorp/terraform-plugin-mux/internal/tf6dynamicvalue" ) // PlanResourceChange calls the PlanResourceChange method, passing `req`, on @@ -22,6 +23,34 @@ func (s muxServer) PlanResourceChange(ctx context.Context, req *tfprotov6.PlanRe } ctx = logging.Tfprotov6ProviderServerContext(ctx, server) + + // Prevent ServerCapabilities.PlanDestroy from sending destroy plans to + // servers which do not enable the capability. + resourceCapabilities := s.resourceCapabilities[req.TypeName] + + if resourceCapabilities == nil || !resourceCapabilities.PlanDestroy { + resourceSchema := s.resourceSchemas[req.TypeName] + + isDestroyPlan, err := tf6dynamicvalue.IsNull(resourceSchema, req.ProposedNewState) + + if err != nil { + return nil, fmt.Errorf("unable to determine if request is destroy plan: %w", err) + } + + if isDestroyPlan { + logging.MuxTrace(ctx, "server does not enable destroy plans, returning without calling downstream server") + + resp := &tfprotov6.PlanResourceChangeResponse{ + // Presumably, we must preserve any prior private state so it + // is still available during ApplyResourceChange. + PlannedPrivate: req.PriorPrivate, + PlannedState: req.ProposedNewState, + } + + return resp, nil + } + } + logging.MuxTrace(ctx, "calling downstream server") return server.PlanResourceChange(ctx, req) diff --git a/tf6muxserver/mux_server_PlanResourceChange_test.go b/tf6muxserver/mux_server_PlanResourceChange_test.go index 8c570f9..b775c23 100644 --- a/tf6muxserver/mux_server_PlanResourceChange_test.go +++ b/tf6muxserver/mux_server_PlanResourceChange_test.go @@ -5,25 +5,64 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-mux/internal/tf6dynamicvalue" "github.com/hashicorp/terraform-plugin-mux/internal/tf6testserver" "github.com/hashicorp/terraform-plugin-mux/tf6muxserver" ) -func TestMuxServerPlanResourceChange(t *testing.T) { +func TestMuxServerPlanResourceChange_Routing(t *testing.T) { t.Parallel() ctx := context.Background() testServer1 := &tf6testserver.TestServer{ ResourceSchemas: map[string]*tfprotov6.Schema{ - "test_resource_server1": {}, + "test_resource_server1": { + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, }, } testServer2 := &tf6testserver.TestServer{ ResourceSchemas: map[string]*tfprotov6.Schema{ - "test_resource_server2": {}, + "test_resource_server2": { + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, }, } + testProposedNewState := tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + // intentionally set for create/update plan + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ) + servers := []func() tfprotov6.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} muxServer, err := tf6muxserver.NewMuxServer(ctx, servers...) @@ -32,7 +71,8 @@ func TestMuxServerPlanResourceChange(t *testing.T) { } _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov6.PlanResourceChangeRequest{ - TypeName: "test_resource_server1", + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server1", }) if err != nil { @@ -48,7 +88,8 @@ func TestMuxServerPlanResourceChange(t *testing.T) { } _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov6.PlanResourceChangeRequest{ - TypeName: "test_resource_server2", + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server2", }) if err != nil { @@ -63,3 +104,100 @@ func TestMuxServerPlanResourceChange(t *testing.T) { t.Errorf("expected test_resource_server2 PlanResourceChange to be called on server2") } } + +func TestMuxServerPlanResourceChange_ServerCapabilities_PlanDestroy(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + testServer1 := &tf6testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_resource_server1": { + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + }, + ServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, + } + testServer2 := &tf6testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_resource_server2": { + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_string_attribute", + Type: tftypes.String, + }, + }, + }, + }, + }, + // Intentionally no ServerCapabilities on this server + } + + testProposedNewState := tf6dynamicvalue.Must( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + nil, // intentionally null for destroy plan + ), + ) + + servers := []func() tfprotov6.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} + muxServer, err := tf6muxserver.NewMuxServer(ctx, servers...) + + if err != nil { + t.Fatalf("unexpected error setting up factory: %s", err) + } + + _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov6.PlanResourceChangeRequest{ + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server1", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !testServer1.PlanResourceChangeCalled["test_resource_server1"] { + t.Errorf("expected test_resource_server1 PlanResourceChange to be called on server1") + } + + if testServer2.PlanResourceChangeCalled["test_resource_server1"] { + t.Errorf("unexpected test_resource_server1 PlanResourceChange called on server2") + } + + _, err = muxServer.ProviderServer().PlanResourceChange(ctx, &tfprotov6.PlanResourceChangeRequest{ + ProposedNewState: testProposedNewState, + TypeName: "test_resource_server2", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if testServer1.PlanResourceChangeCalled["test_resource_server2"] { + t.Errorf("unexpected test_resource_server2 PlanResourceChange called on server1") + } + + // Server does not enable ServerCapabilities.PlanDestroy + if testServer2.PlanResourceChangeCalled["test_resource_server2"] { + t.Errorf("unexpected test_resource_server2 PlanResourceChange called on server2") + } +} diff --git a/tf6muxserver/mux_server_ValidateProviderConfig.go b/tf6muxserver/mux_server_ValidateProviderConfig.go index 9fdf62c..86ba375 100644 --- a/tf6muxserver/mux_server_ValidateProviderConfig.go +++ b/tf6muxserver/mux_server_ValidateProviderConfig.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-mux/internal/logging" + "github.com/hashicorp/terraform-plugin-mux/internal/tf6dynamicvalue" ) // ValidateProviderConfig calls the ValidateProviderConfig method on each server @@ -48,7 +49,7 @@ func (s muxServer) ValidateProviderConfig(ctx context.Context, req *tfprotov6.Va continue } - equal, err := dynamicValueEquals(s.providerSchema.ValueType(), res.PreparedConfig, resp.PreparedConfig) + equal, err := tf6dynamicvalue.Equals(s.providerSchema.ValueType(), res.PreparedConfig, resp.PreparedConfig) if err != nil { return nil, fmt.Errorf("unable to compare PrepareProviderConfig PreparedConfig responses: %w", err) diff --git a/tf6muxserver/mux_server_test.go b/tf6muxserver/mux_server_test.go index 8c8aab8..39de46d 100644 --- a/tf6muxserver/mux_server_test.go +++ b/tf6muxserver/mux_server_test.go @@ -594,6 +594,24 @@ func TestNewMuxServer(t *testing.T) { }).ProviderServer, }, }, + "server-capabilities": { + servers: []func() tfprotov6.ProviderServer{ + (&tf6testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_with_server_capabilities": {}, + }, + ServerCapabilities: &tfprotov6.ServerCapabilities{ + PlanDestroy: true, + }, + }).ProviderServer, + (&tf6testserver.TestServer{ + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_without_server_capabilities": {}, + }, + }).ProviderServer, + }, + expectedError: nil, + }, } for name, testCase := range testCases {