Skip to content

Commit 555d60c

Browse files
Add DropEmptyStruct to ssa patch helper
1 parent d1e0fd4 commit 555d60c

File tree

4 files changed

+112
-14
lines changed

4 files changed

+112
-14
lines changed

internal/controllers/topology/cluster/structuredmerge/dryrun.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ func dryRunSSAPatch(ctx context.Context, dryRunCtx *dryRunSSAPatchInput) (bool,
6868
// For dry run we use the same options as for the intent but with adding metadata.managedFields
6969
// to ensure that changes to ownership are detected.
7070
filterObjectInput := &ssa.FilterObjectInput{
71-
AllowedPaths: append(dryRunCtx.helperOptions.AllowedPaths, []string{"metadata", "managedFields"}),
72-
IgnorePaths: dryRunCtx.helperOptions.IgnorePaths,
71+
AllowedPaths: append(dryRunCtx.helperOptions.AllowedPaths, []string{"metadata", "managedFields"}),
72+
IgnorePaths: dryRunCtx.helperOptions.IgnorePaths,
73+
DropEmptyStruct: dryRunCtx.helperOptions.DropEmptyStruct,
7374
}
7475

7576
// Add TopologyDryRunAnnotation to notify validation webhooks to skip immutability checks.

internal/controllers/topology/cluster/structuredmerge/options.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package structuredmerge
1818

1919
import (
20+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2021
"sigs.k8s.io/controller-runtime/pkg/client"
2122

2223
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
@@ -78,15 +79,26 @@ type HelperOptions struct {
7879
func newHelperOptions(target client.Object, opts ...HelperOption) *HelperOptions {
7980
helperOptions := &HelperOptions{
8081
FilterObjectInput: ssa.FilterObjectInput{
81-
AllowedPaths: defaultAllowedPaths,
82-
IgnorePaths: []contract.Path{},
82+
AllowedPaths: defaultAllowedPaths,
83+
IgnorePaths: []contract.Path{},
84+
DropEmptyStruct: false,
8385
},
8486
}
8587
// Overwrite the allowedPaths for Cluster objects to prevent the topology controller
8688
// to take ownership of fields it is not supposed to.
87-
if _, ok := target.(*clusterv1.Cluster); ok {
89+
switch target.(type) {
90+
case *clusterv1.Cluster:
8891
helperOptions.AllowedPaths = allowedPathsCluster
92+
// NOTE: DropEmptyStruct is not required for the cluster, because even if it is converted to unstructured using the DefaultUnstructuredConverter,
93+
// and it does not handle omitzero (yet), none of the allowedPaths is using omitzero.
94+
case *unstructured.Unstructured:
95+
// NOTE: DropEmptyStruct is not required for unstructured objects, because DefaultUnstructuredConverter is not called.
96+
default:
97+
helperOptions.DropEmptyStruct = true
98+
// NOTE: DropEmptyStruct is required for typed objects, because they are converted to unstructured using the DefaultUnstructuredConverter,
99+
// and it does not handle omitzero (yet).
89100
}
101+
90102
helperOptions = helperOptions.ApplyOptions(opts)
91103
return helperOptions
92104
}
@@ -109,3 +121,13 @@ type IgnorePaths []contract.Path
109121
func (i IgnorePaths) ApplyToHelper(opts *HelperOptions) {
110122
opts.IgnorePaths = i
111123
}
124+
125+
// DropEmptyStruct instruct the Helper to drop all fields with values equals to empty struct.
126+
// NOTE: This is required when using typed objects, because the DefaultUnstructuredConverter does
127+
// not handle omitzero (yet).
128+
type DropEmptyStruct bool
129+
130+
// ApplyToHelper applies this configuration to the given helper options.
131+
func (i DropEmptyStruct) ApplyToHelper(opts *HelperOptions) {
132+
opts.DropEmptyStruct = bool(i)
133+
}

internal/util/ssa/filterintent.go

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ limitations under the License.
1717
package ssa
1818

1919
import (
20+
"reflect"
21+
2022
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2123

2224
"sigs.k8s.io/cluster-api/internal/contract"
@@ -32,26 +34,43 @@ type FilterObjectInput struct {
3234
// spec.ControlPlaneEndpoint.
3335
// NOTE: ignore paths which point to an array are not supported by the current implementation.
3436
IgnorePaths []contract.Path
37+
38+
// DropEmptyStruct instruct the Helper to drop all fields with values equals to empty struct.
39+
// NOTE: This is required when using typed objects, because the DefaultUnstructuredConverter does
40+
// not handle omitzero (yet).
41+
DropEmptyStruct bool
3542
}
3643

3744
// FilterObject filter out changes not relevant for the controller.
3845
func FilterObject(obj *unstructured.Unstructured, input *FilterObjectInput) {
3946
// filter out changes not in the allowed paths (fields to not consider, e.g. status);
47+
// also drop empty struct if required.
4048
if len(input.AllowedPaths) > 0 {
4149
FilterIntent(&FilterIntentInput{
42-
Path: contract.Path{},
43-
Value: obj.Object,
44-
ShouldFilter: IsPathNotAllowed(input.AllowedPaths),
50+
Path: contract.Path{},
51+
Value: obj.Object,
52+
ShouldFilter: IsPathNotAllowed(input.AllowedPaths),
53+
DropEmptyStruct: input.DropEmptyStruct,
4554
})
4655
}
4756

4857
// filter out changes for ignore paths (well known fields owned by other controllers, e.g.
49-
// spec.controlPlaneEndpoint in the InfrastructureCluster object);
58+
// spec.controlPlaneEndpoint in the InfrastructureCluster object); also drop empty struct if required.
5059
if len(input.IgnorePaths) > 0 {
5160
FilterIntent(&FilterIntentInput{
52-
Path: contract.Path{},
53-
Value: obj.Object,
54-
ShouldFilter: IsPathIgnored(input.IgnorePaths),
61+
Path: contract.Path{},
62+
Value: obj.Object,
63+
ShouldFilter: IsPathIgnored(input.IgnorePaths),
64+
DropEmptyStruct: input.DropEmptyStruct,
65+
})
66+
}
67+
68+
// DropEmptyStruct if not already done above.
69+
if input.DropEmptyStruct && len(input.AllowedPaths) == 0 && len(input.IgnorePaths) == 0 {
70+
FilterIntent(&FilterIntentInput{
71+
Path: contract.Path{},
72+
Value: obj.Object,
73+
DropEmptyStruct: input.DropEmptyStruct,
5574
})
5675
}
5776
}
@@ -76,11 +95,19 @@ func FilterIntent(ctx *FilterIntentInput) bool {
7695
// Gets the original and the modified Value for the field.
7796
Value: value[field],
7897
// Carry over global values from the context.
79-
ShouldFilter: ctx.ShouldFilter,
98+
ShouldFilter: ctx.ShouldFilter,
99+
DropEmptyStruct: ctx.DropEmptyStruct,
80100
}
81101

82102
// If the field should be filtered out, delete it from the modified object.
83-
if fieldCtx.ShouldFilter(fieldCtx.Path) {
103+
if fieldCtx.ShouldFilter != nil && fieldCtx.ShouldFilter(fieldCtx.Path) {
104+
delete(value, field)
105+
gotDeletions = true
106+
continue
107+
}
108+
109+
// If empty struct should be dropped and the value is a empty struct, delete it from the modified object.
110+
if fieldCtx.DropEmptyStruct && reflect.DeepEqual(fieldCtx.Value, map[string]interface{}{}) {
84111
delete(value, field)
85112
gotDeletions = true
86113
continue
@@ -109,6 +136,8 @@ type FilterIntentInput struct {
109136

110137
// ShouldFilter handle the func that determine if the current Path should be dropped or not.
111138
ShouldFilter func(path contract.Path) bool
139+
140+
DropEmptyStruct bool
112141
}
113142

114143
// IsPathAllowed returns true when the Path is one of the AllowedPaths.

internal/util/ssa/filterintent_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,49 @@ func Test_filterIgnoredPaths(t *testing.T) {
199199
})
200200
}
201201
}
202+
203+
func Test_filterDropEmptyStruct(t *testing.T) {
204+
tests := []struct {
205+
name string
206+
ctx *FilterIntentInput
207+
wantValue map[string]interface{}
208+
}{
209+
{
210+
name: "Cleanup empty maps",
211+
ctx: &FilterIntentInput{
212+
Path: contract.Path{},
213+
Value: map[string]interface{}{
214+
"spec": map[string]interface{}{},
215+
},
216+
DropEmptyStruct: true,
217+
},
218+
wantValue: map[string]interface{}{
219+
// we are filtering out spec.foo and then spec given that it is an empty map
220+
},
221+
},
222+
{
223+
name: "Cleanup empty nested maps",
224+
ctx: &FilterIntentInput{
225+
Path: contract.Path{},
226+
Value: map[string]interface{}{
227+
"spec": map[string]interface{}{
228+
"bar": map[string]interface{}{},
229+
},
230+
},
231+
DropEmptyStruct: true,
232+
},
233+
wantValue: map[string]interface{}{
234+
// we are filtering out spec.bar.foo and then spec given that it is an empty map
235+
},
236+
},
237+
}
238+
for _, tt := range tests {
239+
t.Run(tt.name, func(t *testing.T) {
240+
g := NewWithT(t)
241+
242+
FilterIntent(tt.ctx)
243+
244+
g.Expect(tt.ctx.Value).To(BeComparableTo(tt.wantValue))
245+
})
246+
}
247+
}

0 commit comments

Comments
 (0)