Skip to content

Commit 37c7f4e

Browse files
authored
Merge pull request #411 from ulucinar/fix-embedded-conversion
Add config.Resource.RemoveSingletonListConversion
2 parents 58f4ba3 + 361331e commit 37c7f4e

File tree

9 files changed

+495
-5
lines changed

9 files changed

+495
-5
lines changed

pkg/config/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ func NewProvider(schema []byte, prefix string, modulePath string, metadata []byt
373373
p.Resources[name].useTerraformPluginFrameworkClient = isPluginFrameworkResource
374374
// traverse the Terraform resource schema to initialize the upjet Resource
375375
// configurations
376-
if err := traverseSchemas(name, terraformResource, p.Resources[name], p.schemaTraversers...); err != nil {
376+
if err := TraverseSchemas(name, p.Resources[name], p.schemaTraversers...); err != nil {
377377
panic(errors.Wrap(err, "failed to execute the Terraform schema traverser chain"))
378378
}
379379
}

pkg/config/resource.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,28 @@ func (r *Resource) AddSingletonListConversion(tfPath, crdPath string) {
693693
r.listConversionPaths[tfPath] = crdPath
694694
}
695695

696+
// RemoveSingletonListConversion removes the singleton list conversion
697+
// for the specified Terraform configuration path. Also unsets the path's
698+
// embedding mode. The specified fieldpath expression must be a Terraform
699+
// field path with or without the wildcard segments. Returns true if
700+
// the path has already been registered for singleton list conversion.
701+
func (r *Resource) RemoveSingletonListConversion(tfPath string) bool {
702+
nPath := strings.ReplaceAll(tfPath, "[*]", "")
703+
nPath = strings.ReplaceAll(nPath, "[0]", "")
704+
for p := range r.listConversionPaths {
705+
n := strings.ReplaceAll(p, "[*]", "")
706+
n = strings.ReplaceAll(n, "[0]", "")
707+
if n == nPath {
708+
delete(r.listConversionPaths, p)
709+
if r.SchemaElementOptions[n] != nil {
710+
r.SchemaElementOptions[n].EmbeddedObject = false
711+
}
712+
return true
713+
}
714+
}
715+
return false
716+
}
717+
696718
// SetEmbeddedObject sets the EmbeddedObject for the specified key.
697719
// The key is a Terraform field path without the wildcard segments.
698720
func (m SchemaElementOptions) SetEmbeddedObject(el string) {

pkg/config/resource_test.go

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const (
2424
provider = "ACoolProvider"
2525
)
2626

27-
func TestTagger_Initialize(t *testing.T) {
27+
func TestTaggerInitialize(t *testing.T) {
2828
errBoom := errors.New("boom")
2929

3030
type args struct {
@@ -112,3 +112,187 @@ func TestSetExternalTagsWithPaved(t *testing.T) {
112112
})
113113
}
114114
}
115+
116+
func TestAddSingletonListConversion(t *testing.T) {
117+
type args struct {
118+
r func() *Resource
119+
tfPath string
120+
crdPath string
121+
}
122+
type want struct {
123+
r func() *Resource
124+
}
125+
cases := map[string]struct {
126+
reason string
127+
args
128+
want
129+
}{
130+
"AddNonWildcardTFPath": {
131+
reason: "A non-wildcard TF path of a singleton list should successfully be configured to be converted into an embedded object.",
132+
args: args{
133+
tfPath: "singleton_list",
134+
crdPath: "singletonList",
135+
r: func() *Resource {
136+
r := DefaultResource("test_resource", nil, nil, nil)
137+
r.AddSingletonListConversion("singleton_list", "singletonList")
138+
return r
139+
},
140+
},
141+
want: want{
142+
r: func() *Resource {
143+
r := DefaultResource("test_resource", nil, nil, nil)
144+
r.SchemaElementOptions = SchemaElementOptions{}
145+
r.SchemaElementOptions["singleton_list"] = &SchemaElementOption{
146+
EmbeddedObject: true,
147+
}
148+
r.listConversionPaths["singleton_list"] = "singletonList"
149+
return r
150+
},
151+
},
152+
},
153+
"AddWildcardTFPath": {
154+
reason: "A wildcard TF path of a singleton list should successfully be configured to be converted into an embedded object.",
155+
args: args{
156+
tfPath: "parent[*].singleton_list",
157+
crdPath: "parent[*].singletonList",
158+
r: func() *Resource {
159+
r := DefaultResource("test_resource", nil, nil, nil)
160+
r.AddSingletonListConversion("parent[*].singleton_list", "parent[*].singletonList")
161+
return r
162+
},
163+
},
164+
want: want{
165+
r: func() *Resource {
166+
r := DefaultResource("test_resource", nil, nil, nil)
167+
r.SchemaElementOptions = SchemaElementOptions{}
168+
r.SchemaElementOptions["parent.singleton_list"] = &SchemaElementOption{
169+
EmbeddedObject: true,
170+
}
171+
r.listConversionPaths["parent[*].singleton_list"] = "parent[*].singletonList"
172+
return r
173+
},
174+
},
175+
},
176+
"AddIndexedTFPath": {
177+
reason: "An indexed TF path of a singleton list should successfully be configured to be converted into an embedded object.",
178+
args: args{
179+
tfPath: "parent[0].singleton_list",
180+
crdPath: "parent[0].singletonList",
181+
r: func() *Resource {
182+
r := DefaultResource("test_resource", nil, nil, nil)
183+
r.AddSingletonListConversion("parent[0].singleton_list", "parent[0].singletonList")
184+
return r
185+
},
186+
},
187+
want: want{
188+
r: func() *Resource {
189+
r := DefaultResource("test_resource", nil, nil, nil)
190+
r.SchemaElementOptions = SchemaElementOptions{}
191+
r.SchemaElementOptions["parent.singleton_list"] = &SchemaElementOption{
192+
EmbeddedObject: true,
193+
}
194+
r.listConversionPaths["parent[0].singleton_list"] = "parent[0].singletonList"
195+
return r
196+
},
197+
},
198+
},
199+
}
200+
for n, tc := range cases {
201+
t.Run(n, func(t *testing.T) {
202+
r := tc.args.r()
203+
r.AddSingletonListConversion(tc.args.tfPath, tc.args.crdPath)
204+
wantR := tc.want.r()
205+
if diff := cmp.Diff(wantR.listConversionPaths, r.listConversionPaths); diff != "" {
206+
t.Errorf("%s\nAddSingletonListConversion(tfPath): -wantConversionPaths, +gotConversionPaths: \n%s", tc.reason, diff)
207+
}
208+
if diff := cmp.Diff(wantR.SchemaElementOptions, r.SchemaElementOptions); diff != "" {
209+
t.Errorf("%s\nAddSingletonListConversion(tfPath): -wantSchemaElementOptions, +gotSchemaElementOptions: \n%s", tc.reason, diff)
210+
}
211+
})
212+
}
213+
}
214+
215+
func TestRemoveSingletonListConversion(t *testing.T) {
216+
type args struct {
217+
r func() *Resource
218+
tfPath string
219+
}
220+
type want struct {
221+
removed bool
222+
r func() *Resource
223+
}
224+
cases := map[string]struct {
225+
reason string
226+
args
227+
want
228+
}{
229+
"RemoveWildcardListConversion": {
230+
reason: "An existing wildcard list conversion can successfully be removed.",
231+
args: args{
232+
tfPath: "parent[*].singleton_list",
233+
r: func() *Resource {
234+
r := DefaultResource("test_resource", nil, nil, nil)
235+
r.AddSingletonListConversion("parent[*].singleton_list", "parent[*].singletonList")
236+
return r
237+
},
238+
},
239+
want: want{
240+
removed: true,
241+
r: func() *Resource {
242+
r := DefaultResource("test_resource", nil, nil, nil)
243+
return r
244+
},
245+
},
246+
},
247+
"RemoveIndexedListConversion": {
248+
reason: "An existing indexed list conversion can successfully be removed.",
249+
args: args{
250+
tfPath: "parent[0].singleton_list",
251+
r: func() *Resource {
252+
r := DefaultResource("test_resource", nil, nil, nil)
253+
r.AddSingletonListConversion("parent[0].singleton_list", "parent[0].singletonList")
254+
return r
255+
},
256+
},
257+
want: want{
258+
removed: true,
259+
r: func() *Resource {
260+
r := DefaultResource("test_resource", nil, nil, nil)
261+
return r
262+
},
263+
},
264+
},
265+
"NonExistingListConversion": {
266+
reason: "A list conversion path that does not exist cannot be removed.",
267+
args: args{
268+
tfPath: "non-existent",
269+
r: func() *Resource {
270+
r := DefaultResource("test_resource", nil, nil, nil)
271+
r.AddSingletonListConversion("parent[*].singleton_list", "parent[*].singletonList")
272+
return r
273+
},
274+
},
275+
want: want{
276+
removed: false,
277+
r: func() *Resource {
278+
r := DefaultResource("test_resource", nil, nil, nil)
279+
r.AddSingletonListConversion("parent[*].singleton_list", "parent[*].singletonList")
280+
return r
281+
},
282+
},
283+
},
284+
}
285+
for n, tc := range cases {
286+
t.Run(n, func(t *testing.T) {
287+
r := tc.args.r()
288+
got := r.RemoveSingletonListConversion(tc.args.tfPath)
289+
if diff := cmp.Diff(tc.want.removed, got); diff != "" {
290+
t.Errorf("%s\nRemoveSingletonListConversion(tfPath): -wantRemoved, +gotRemoved: \n%s", tc.reason, diff)
291+
}
292+
293+
if diff := cmp.Diff(tc.want.r().listConversionPaths, r.listConversionPaths); diff != "" {
294+
t.Errorf("%s\nRemoveSingletonListConversion(tfPath): -wantConversionPaths, +gotConversionPaths: \n%s", tc.reason, diff)
295+
}
296+
})
297+
}
298+
}

pkg/config/schema_conversions.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package config
66

77
import (
88
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
9+
"github.com/pkg/errors"
910

1011
"github.com/crossplane/upjet/pkg/schema/traverser"
1112
)
@@ -17,15 +18,34 @@ type ResourceSetter interface {
1718
SetResource(r *Resource)
1819
}
1920

20-
func traverseSchemas(tfName string, tfResource *schema.Resource, r *Resource, visitors ...traverser.SchemaTraverser) error {
21+
// ResourceSchema represents a provider's resource schema.
22+
type ResourceSchema map[string]*Resource
23+
24+
// TraverseTFSchemas traverses the Terraform schemas of all the resources of
25+
// the Provider `p` using the specified visitors. Reports any errors
26+
// encountered.
27+
func (s ResourceSchema) TraverseTFSchemas(visitors ...traverser.SchemaTraverser) error {
28+
for name, cfg := range s {
29+
if err := TraverseSchemas(name, cfg, visitors...); err != nil {
30+
return errors.Wrapf(err, "failed to traverse the schema of the Terraform resource with name %q", name)
31+
}
32+
}
33+
return nil
34+
}
35+
36+
// TraverseSchemas visits the specified schema belonging to the Terraform
37+
// resource with the given name and given upjet resource configuration using
38+
// the specified visitors. If any visitors report an error, traversal is
39+
// stopped and the error is reported to the caller.
40+
func TraverseSchemas(tfName string, r *Resource, visitors ...traverser.SchemaTraverser) error {
2141
// set the upjet Resource configuration as context for the visitors that
2242
// satisfy the ResourceSetter interface.
2343
for _, v := range visitors {
2444
if rs, ok := v.(ResourceSetter); ok {
2545
rs.SetResource(r)
2646
}
2747
}
28-
return traverser.Traverse(tfName, tfResource, visitors...)
48+
return traverser.Traverse(tfName, r.TerraformResource, visitors...)
2949
}
3050

3151
type resourceContext struct {

pkg/config/schema_conversions_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ func TestSingletonListEmbedder(t *testing.T) {
155155
t.Run(n, func(t *testing.T) {
156156
e := &SingletonListEmbedder{}
157157
r := DefaultResource(tt.args.name, tt.args.resource, nil, nil)
158-
err := traverseSchemas(tt.args.name, tt.args.resource, r, e)
158+
s := ResourceSchema{
159+
tt.args.name: r,
160+
}
161+
err := s.TraverseTFSchemas(e)
159162
if diff := cmp.Diff(tt.want.err, err, test.EquateErrors()); diff != "" {
160163
t.Fatalf("\n%s\ntraverseSchemas(name, schema, ...): -wantErr, +gotErr:\n%s", tt.reason, diff)
161164
}

pkg/schema/traverser/access.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-FileCopyrightText: 2024 The Crossplane Authors <https://crossplane.io>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package traverser
6+
7+
import (
8+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
9+
"github.com/pkg/errors"
10+
)
11+
12+
// SchemaAccessor is a function that accesses and potentially modifies a
13+
// Terraform schema.
14+
type SchemaAccessor func(*schema.Schema) error
15+
16+
// AccessSchema accesses the schema element at the specified path and calls
17+
// the given schema accessors in order. Reports any errors encountered by
18+
// the accessors. The terminal node at the end of the specified path must be
19+
// a *schema.Resource or an error will be reported. The specified path must
20+
// have at least one component.
21+
func AccessSchema(sch any, path []string, accessors ...SchemaAccessor) error { //nolint:gocyclo // easier to follow the flow
22+
if len(path) == 0 {
23+
return errors.New("empty path specified while accessing the Terraform resource schema")
24+
}
25+
k := path[0]
26+
path = path[1:]
27+
switch s := sch.(type) {
28+
case *schema.Schema:
29+
if len(path) == 0 {
30+
return errors.Errorf("terminal node at key %q is a *schema.Schema", k)
31+
}
32+
if k != wildcard {
33+
return errors.Errorf("expecting a wildcard key but encountered the key %q", k)
34+
}
35+
if err := AccessSchema(s.Elem, path, accessors...); err != nil {
36+
return err
37+
}
38+
case *schema.Resource:
39+
c := s.Schema[k]
40+
if c == nil {
41+
return errors.Errorf("schema element for key %q is nil", k)
42+
}
43+
if len(path) == 0 {
44+
for _, a := range accessors {
45+
if err := a(c); err != nil {
46+
return errors.Wrapf(err, "failed to call the accessor function on the schema element at key %q", k)
47+
}
48+
}
49+
return nil
50+
}
51+
if err := AccessSchema(c, path, accessors...); err != nil {
52+
return err
53+
}
54+
default:
55+
return errors.Errorf("unknown schema element type %T at key %q", s, k)
56+
}
57+
return nil
58+
}

pkg/schema/traverser/maxitemssync.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// SPDX-FileCopyrightText: 2024 The Crossplane Authors <https://crossplane.io>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package traverser
6+
7+
import (
8+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
9+
"github.com/pkg/errors"
10+
)
11+
12+
// maxItemsSync is a visitor to sync the MaxItems constraints from the
13+
// Go schema to the JSON schema. We've observed that some MaxItems constraints
14+
// in the JSON schemas are not set where the corresponding MaxItems constraints
15+
// in the Go schemas are set to 1. This inconsistency results in some singleton
16+
// lists not being properly converted in the MR API whereas at runtime we may
17+
// try to invoke the corresponding Terraform conversion functions. This
18+
// traverser can mitigate such inconsistencies by syncing the MaxItems
19+
// constraints from the Go schema to the JSON schema.
20+
type maxItemsSync struct {
21+
NoopTraverser
22+
23+
jsonSchema TFResourceSchema
24+
}
25+
26+
// NewMaxItemsSync returns a new schema traverser capable of
27+
// syncing the MaxItems constraints from the Go schema to the specified JSON
28+
// schema. This will ensure the generation time and the runtime schema MaxItems
29+
// constraints will be consistent. This is needed only if the generation time
30+
// and runtime schemas differ.
31+
func NewMaxItemsSync(s TFResourceSchema) SchemaTraverser {
32+
return &maxItemsSync{
33+
jsonSchema: s,
34+
}
35+
}
36+
37+
func (m *maxItemsSync) VisitResource(r *ResourceNode) error {
38+
// this visitor only works on singleton lists
39+
if (r.Schema.Type != schema.TypeList && r.Schema.Type != schema.TypeSet) || r.Schema.MaxItems != 1 {
40+
return nil
41+
}
42+
return errors.Wrapf(AccessSchema(m.jsonSchema[r.TFName], r.TFPath,
43+
func(s *schema.Schema) error {
44+
s.MaxItems = 1
45+
return nil
46+
}), "failed to access the schema element at path %v for resource %q", r.TFPath, r.TFName)
47+
}

0 commit comments

Comments
 (0)