Skip to content

Commit cc49ffd

Browse files
authored
[Internal] Add ConvertToAttribute() to convert blocks in a resource/data source schema to attributes (#4284)
## Changes Blocks in the Plugin Framework cannot be computed. Terraform checks that the number of blocks in the plan matches the number in the config (https://github.com/hashicorp/terraform/blob/81441564dd29b4aa5fb9576323ef90b9787d1967/internal/plans/objchange/plan_valid.go#L115). Today, for compatibility with existing resources in the TF provider implemented with the SDKv2, we treat nested structures as lists with a single element. As a result, this precludes any nested structures from being read-only (since those would be converted into ListNestedBlocks, which cannot be computed). This blocks databricks_app from being implemented on the plugin framework, as its app_status and compute_status fields are objects and not primitives. Note that TF recommends computing computed-only blocks to attributes [here](https://developer.hashicorp.com/terraform/plugin/framework/migrating/attributes-blocks/blocks-computed). Other useful discussions can be found [here](https://discuss.hashicorp.com/t/set-list-attribute-migration-from-sdkv2-to-framework/56472) and [here](https://discuss.hashicorp.com/t/optional-computed-block-handling-in-plugin-framework/56337). The migration guide for blocks can be found [here](https://developer.hashicorp.com/terraform/plugin/framework/migrating/attributes-blocks/blocks). As an intermediate step, we add `ConvertToAttribute()` in `CustomizableSchema`. This allows resource maintainers to convert specific, known read-only blocks to attributes in accordance with [the migration guide](https://developer.hashicorp.com/terraform/plugin/framework/migrating/attributes-blocks/blocks-computed), which then can be marked as read-only. As blocks can contain other blocks, this method also recursively converts nested blocks into nested attributes. This is based on #4283, so we should wait for that PR to merge & rebase this before merging this. ## Tests Unit tests validate that ListNestedBlocks are converted to ListNestedAttributes and that SingleNestedBlocks are converted to SingleNestedAttributes.
1 parent 76d2659 commit cc49ffd

File tree

8 files changed

+329
-5
lines changed

8 files changed

+329
-5
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package tfschema
2+
3+
type BlockToAttributeConverter interface {
4+
// ConvertBlockToAttribute converts a contained block to its corresponding attribute type.
5+
ConvertBlockToAttribute(string) BaseSchemaBuilder
6+
}

internal/providers/pluginfw/tfschema/block_builder.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import (
1010
// This common interface prevents us from keeping two copies of StructToSchema and CustomizableSchema.
1111
type BlockBuilder interface {
1212
BaseSchemaBuilder
13+
14+
// ToAttribute converts a block to its corresponding attribute type. Currently, ResourceStructToSchema converts all
15+
// nested struct fields and slices to blocks. This method is used to convert those blocks to their corresponding
16+
// attribute type. The resulting attribute will not have any of the Computed/Optional/Required/Sensitive flags set.
17+
ToAttribute() AttributeBuilder
18+
1319
BuildDataSourceBlock() dataschema.Block
1420
BuildResourceBlock() schema.Block
1521
}

internal/providers/pluginfw/tfschema/customizable_schema.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
)
1111

1212
// CustomizableSchema is a wrapper struct on top of BaseSchemaBuilder that can be used to navigate through nested schema add customizations.
13+
// The methods of CustomizableSchema that modify the underlying schema should return the same CustomizableSchema object to allow chaining.
1314
type CustomizableSchema struct {
1415
attr BaseSchemaBuilder
1516
}
@@ -199,6 +200,35 @@ func (s *CustomizableSchema) SetReadOnly(path ...string) *CustomizableSchema {
199200
return s
200201
}
201202

203+
// ConvertToAttribute converts the last element of the path from a block to an attribute.
204+
// It panics if the path is empty, if the path does not exist in the schema, or if the path
205+
// points to an attribute, not a block.
206+
func (s *CustomizableSchema) ConvertToAttribute(path ...string) *CustomizableSchema {
207+
if len(path) == 0 {
208+
panic(fmt.Errorf("ConvertToAttribute called on root schema. %s", common.TerraformBugErrorMessage))
209+
}
210+
field := path[len(path)-1]
211+
212+
cb := func(attr BaseSchemaBuilder) BaseSchemaBuilder {
213+
switch a := attr.(type) {
214+
case BlockToAttributeConverter:
215+
return a.ConvertBlockToAttribute(field)
216+
default:
217+
panic(fmt.Errorf("ConvertToAttribute called on invalid attribute type: %s. %s", reflect.TypeOf(attr).String(), common.TerraformBugErrorMessage))
218+
}
219+
}
220+
221+
// We have to go only as far as the second-to-last entry, since we need to change the parent schema
222+
// by moving the last entry from a block to an attribute.
223+
if len(path) == 1 {
224+
s.attr = cb(s.attr)
225+
} else {
226+
navigateSchemaWithCallback(&s.attr, cb, path[0:len(path)-1]...)
227+
}
228+
229+
return s
230+
}
231+
202232
// navigateSchemaWithCallback navigates through schema attributes and executes callback on the target, panics if path does not exist or invalid.
203233
func navigateSchemaWithCallback(s *BaseSchemaBuilder, cb func(BaseSchemaBuilder) BaseSchemaBuilder, path ...string) (BaseSchemaBuilder, error) {
204234
currentScm := s

internal/providers/pluginfw/tfschema/customizable_schema_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
9+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
910
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
1011
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
1112
"github.com/hashicorp/terraform-plugin-framework/types"
@@ -175,3 +176,219 @@ func TestCustomizeSchema_SetComputed_PanicOnBlock(t *testing.T) {
175176
})
176177
})
177178
}
179+
180+
type mockPlanModifier struct{}
181+
182+
// Description implements planmodifier.List.
183+
func (m mockPlanModifier) Description(context.Context) string {
184+
panic("unimplemented")
185+
}
186+
187+
// MarkdownDescription implements planmodifier.List.
188+
func (m mockPlanModifier) MarkdownDescription(context.Context) string {
189+
panic("unimplemented")
190+
}
191+
192+
// PlanModifyList implements planmodifier.List.
193+
func (m mockPlanModifier) PlanModifyList(context.Context, planmodifier.ListRequest, *planmodifier.ListResponse) {
194+
panic("unimplemented")
195+
}
196+
197+
// PlanModifyList implements planmodifier.List.
198+
func (m mockPlanModifier) PlanModifyObject(context.Context, planmodifier.ObjectRequest, *planmodifier.ObjectResponse) {
199+
panic("unimplemented")
200+
}
201+
202+
var _ planmodifier.List = mockPlanModifier{}
203+
var _ planmodifier.Object = mockPlanModifier{}
204+
205+
type mockValidator struct{}
206+
207+
// Description implements validator.List.
208+
func (m mockValidator) Description(context.Context) string {
209+
panic("unimplemented")
210+
}
211+
212+
// MarkdownDescription implements validator.List.
213+
func (m mockValidator) MarkdownDescription(context.Context) string {
214+
panic("unimplemented")
215+
}
216+
217+
// ValidateList implements validator.List.
218+
func (m mockValidator) ValidateList(context.Context, validator.ListRequest, *validator.ListResponse) {
219+
panic("unimplemented")
220+
}
221+
222+
// ValidateList implements validator.Object.
223+
func (m mockValidator) ValidateObject(context.Context, validator.ObjectRequest, *validator.ObjectResponse) {
224+
panic("unimplemented")
225+
}
226+
227+
var _ validator.List = mockValidator{}
228+
var _ validator.Object = mockValidator{}
229+
230+
func TestCustomizeSchema_ConvertToAttribute(t *testing.T) {
231+
v := mockValidator{}
232+
pm := mockPlanModifier{}
233+
testCases := []struct {
234+
name string
235+
baseSchema NestedBlockObject
236+
path []string
237+
want NestedBlockObject
238+
expectPanic bool
239+
}{
240+
{
241+
name: "ListNestedBlock",
242+
baseSchema: NestedBlockObject{
243+
Blocks: map[string]BlockBuilder{
244+
"list": ListNestedBlockBuilder{
245+
NestedObject: NestedBlockObject{
246+
Attributes: map[string]AttributeBuilder{
247+
"attr": StringAttributeBuilder{},
248+
},
249+
},
250+
DeprecationMessage: "deprecated",
251+
Validators: []validator.List{v},
252+
PlanModifiers: []planmodifier.List{pm},
253+
},
254+
},
255+
},
256+
path: []string{"list"},
257+
want: NestedBlockObject{
258+
Attributes: map[string]AttributeBuilder{
259+
"list": ListNestedAttributeBuilder{
260+
NestedObject: NestedAttributeObject{
261+
Attributes: map[string]AttributeBuilder{
262+
"attr": StringAttributeBuilder{},
263+
},
264+
},
265+
DeprecationMessage: "deprecated",
266+
Validators: []validator.List{v},
267+
PlanModifiers: []planmodifier.List{pm},
268+
},
269+
},
270+
},
271+
},
272+
{
273+
name: "ListNestedBlock/CalledOnInnerBlock",
274+
baseSchema: NestedBlockObject{
275+
Blocks: map[string]BlockBuilder{
276+
"list": ListNestedBlockBuilder{
277+
NestedObject: NestedBlockObject{
278+
Blocks: map[string]BlockBuilder{
279+
"nested_block": ListNestedBlockBuilder{
280+
NestedObject: NestedBlockObject{
281+
Attributes: map[string]AttributeBuilder{
282+
"attr": StringAttributeBuilder{},
283+
},
284+
},
285+
},
286+
},
287+
},
288+
},
289+
},
290+
},
291+
path: []string{"list", "nested_block"},
292+
want: NestedBlockObject{
293+
Blocks: map[string]BlockBuilder{
294+
"list": ListNestedBlockBuilder{
295+
NestedObject: NestedBlockObject{
296+
Attributes: map[string]AttributeBuilder{
297+
"nested_block": ListNestedAttributeBuilder{
298+
NestedObject: NestedAttributeObject{
299+
Attributes: map[string]AttributeBuilder{
300+
"attr": StringAttributeBuilder{},
301+
},
302+
},
303+
},
304+
},
305+
},
306+
},
307+
},
308+
},
309+
},
310+
{
311+
name: "SingleNestedBlock",
312+
baseSchema: NestedBlockObject{
313+
Blocks: map[string]BlockBuilder{
314+
"single": SingleNestedBlockBuilder{
315+
NestedObject: NestedBlockObject{
316+
Attributes: map[string]AttributeBuilder{
317+
"attr": StringAttributeBuilder{},
318+
},
319+
},
320+
DeprecationMessage: "deprecated",
321+
Validators: []validator.Object{v},
322+
PlanModifiers: []planmodifier.Object{pm},
323+
},
324+
},
325+
},
326+
path: []string{"single"},
327+
want: NestedBlockObject{
328+
Attributes: map[string]AttributeBuilder{
329+
"single": SingleNestedAttributeBuilder{
330+
Attributes: map[string]AttributeBuilder{
331+
"attr": StringAttributeBuilder{},
332+
},
333+
DeprecationMessage: "deprecated",
334+
Validators: []validator.Object{v},
335+
PlanModifiers: []planmodifier.Object{pm},
336+
},
337+
},
338+
},
339+
},
340+
{
341+
name: "SingleNestedBlock/RecursiveBlocks",
342+
baseSchema: NestedBlockObject{
343+
Blocks: map[string]BlockBuilder{
344+
"single": SingleNestedBlockBuilder{
345+
NestedObject: NestedBlockObject{
346+
Blocks: map[string]BlockBuilder{
347+
"nested_block": ListNestedBlockBuilder{
348+
NestedObject: NestedBlockObject{
349+
Attributes: map[string]AttributeBuilder{
350+
"attr": StringAttributeBuilder{},
351+
},
352+
},
353+
},
354+
},
355+
},
356+
},
357+
},
358+
},
359+
path: []string{"single"},
360+
want: NestedBlockObject{
361+
Attributes: map[string]AttributeBuilder{
362+
"single": SingleNestedAttributeBuilder{
363+
Attributes: map[string]AttributeBuilder{
364+
"nested_block": ListNestedAttributeBuilder{
365+
NestedObject: NestedAttributeObject{
366+
Attributes: map[string]AttributeBuilder{
367+
"attr": StringAttributeBuilder{},
368+
},
369+
},
370+
},
371+
},
372+
},
373+
},
374+
},
375+
},
376+
{
377+
name: "PanicOnEmptyPath",
378+
path: nil,
379+
expectPanic: true,
380+
},
381+
}
382+
for _, c := range testCases {
383+
t.Run(c.name, func(t *testing.T) {
384+
if c.expectPanic {
385+
assert.Panics(t, func() {
386+
ConstructCustomizableSchema(c.baseSchema).ConvertToAttribute(c.path...)
387+
})
388+
} else {
389+
got := ConstructCustomizableSchema(c.baseSchema).ConvertToAttribute(c.path...)
390+
assert.Equal(t, c.want, got.attr.(SingleNestedBlockBuilder).NestedObject)
391+
}
392+
})
393+
}
394+
}

internal/providers/pluginfw/tfschema/list_nested_attribute.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (a ListNestedAttributeBuilder) BuildResourceAttribute() schema.Attribute {
4444
}
4545
}
4646

47-
func (a ListNestedAttributeBuilder) SetOptional() BaseSchemaBuilder {
47+
func (a ListNestedAttributeBuilder) SetOptional() AttributeBuilder {
4848
if a.Optional && !a.Required {
4949
panic("attribute is already optional")
5050
}
@@ -53,7 +53,7 @@ func (a ListNestedAttributeBuilder) SetOptional() BaseSchemaBuilder {
5353
return a
5454
}
5555

56-
func (a ListNestedAttributeBuilder) SetRequired() BaseSchemaBuilder {
56+
func (a ListNestedAttributeBuilder) SetRequired() AttributeBuilder {
5757
if !a.Optional && a.Required {
5858
panic("attribute is already required")
5959
}
@@ -62,23 +62,23 @@ func (a ListNestedAttributeBuilder) SetRequired() BaseSchemaBuilder {
6262
return a
6363
}
6464

65-
func (a ListNestedAttributeBuilder) SetSensitive() BaseSchemaBuilder {
65+
func (a ListNestedAttributeBuilder) SetSensitive() AttributeBuilder {
6666
if a.Sensitive {
6767
panic("attribute is already sensitive")
6868
}
6969
a.Sensitive = true
7070
return a
7171
}
7272

73-
func (a ListNestedAttributeBuilder) SetComputed() BaseSchemaBuilder {
73+
func (a ListNestedAttributeBuilder) SetComputed() AttributeBuilder {
7474
if a.Computed {
7575
panic("attribute is already computed")
7676
}
7777
a.Computed = true
7878
return a
7979
}
8080

81-
func (a ListNestedAttributeBuilder) SetReadOnly() BaseSchemaBuilder {
81+
func (a ListNestedAttributeBuilder) SetReadOnly() AttributeBuilder {
8282
if a.Computed && !a.Optional && !a.Required {
8383
panic("attribute is already read only")
8484
}

internal/providers/pluginfw/tfschema/list_nested_block.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package tfschema
22

33
import (
4+
"fmt"
5+
46
dataschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
57
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
68
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
@@ -16,6 +18,15 @@ type ListNestedBlockBuilder struct {
1618
PlanModifiers []planmodifier.List
1719
}
1820

21+
func (a ListNestedBlockBuilder) ToAttribute() AttributeBuilder {
22+
return ListNestedAttributeBuilder{
23+
NestedObject: a.NestedObject.ToNestedAttributeObject(),
24+
DeprecationMessage: a.DeprecationMessage,
25+
Validators: a.Validators,
26+
PlanModifiers: a.PlanModifiers,
27+
}
28+
}
29+
1930
func (a ListNestedBlockBuilder) BuildDataSourceBlock() dataschema.Block {
2031
return dataschema.ListNestedBlock{
2132
NestedObject: a.NestedObject.BuildDataSourceAttribute(),
@@ -47,3 +58,19 @@ func (a ListNestedBlockBuilder) AddPlanModifier(v planmodifier.List) BaseSchemaB
4758
a.PlanModifiers = append(a.PlanModifiers, v)
4859
return a
4960
}
61+
62+
func (a ListNestedBlockBuilder) ConvertBlockToAttribute(field string) BaseSchemaBuilder {
63+
elem, ok := a.NestedObject.Blocks[field]
64+
if !ok {
65+
panic(fmt.Errorf("field %s does not exist in nested block", field))
66+
}
67+
if a.NestedObject.Attributes == nil {
68+
a.NestedObject.Attributes = make(map[string]AttributeBuilder)
69+
}
70+
a.NestedObject.Attributes[field] = elem.ToAttribute()
71+
delete(a.NestedObject.Blocks, field)
72+
if len(a.NestedObject.Blocks) == 0 {
73+
a.NestedObject.Blocks = nil
74+
}
75+
return a
76+
}

0 commit comments

Comments
 (0)