diff --git a/api/bootstrap/kubeadm/v1beta1/conversion.go b/api/bootstrap/kubeadm/v1beta1/conversion.go index d8787f009e3c..54c19d9699d7 100644 --- a/api/bootstrap/kubeadm/v1beta1/conversion.go +++ b/api/bootstrap/kubeadm/v1beta1/conversion.go @@ -859,6 +859,31 @@ func Convert_v1beta2_ClusterConfiguration_To_v1beta1_ClusterConfiguration(in *bo return autoConvert_v1beta2_ClusterConfiguration_To_v1beta1_ClusterConfiguration(in, out, s) } +func Convert_v1beta1_User_To_v1beta2_User(in *User, out *bootstrapv1.User, s apimachineryconversion.Scope) error { + if err := autoConvert_v1beta1_User_To_v1beta2_User(in, out, s); err != nil { + return err + } + if in.PasswdFrom != nil { + if err := Convert_v1beta1_PasswdSource_To_v1beta2_PasswdSource(in.PasswdFrom, &out.PasswdFrom, s); err != nil { + return err + } + } + return nil +} + +func Convert_v1beta2_User_To_v1beta1_User(in *bootstrapv1.User, out *User, s apimachineryconversion.Scope) error { + if err := autoConvert_v1beta2_User_To_v1beta1_User(in, out, s); err != nil { + return err + } + if in.PasswdFrom.IsDefined() { + out.PasswdFrom = &PasswdSource{} + if err := Convert_v1beta2_PasswdSource_To_v1beta1_PasswdSource(&in.PasswdFrom, out.PasswdFrom, s); err != nil { + return err + } + } + return nil +} + func Convert_v1beta1_File_To_v1beta2_File(in *File, out *bootstrapv1.File, s apimachineryconversion.Scope) error { if err := autoConvert_v1beta1_File_To_v1beta2_File(in, out, s); err != nil { return err diff --git a/api/bootstrap/kubeadm/v1beta1/conversion_test.go b/api/bootstrap/kubeadm/v1beta1/conversion_test.go index be8e0ac8bdb5..00169a24be4c 100644 --- a/api/bootstrap/kubeadm/v1beta1/conversion_test.go +++ b/api/bootstrap/kubeadm/v1beta1/conversion_test.go @@ -168,6 +168,12 @@ func spokeKubeadmConfigSpec(in *KubeadmConfigSpec, c randfill.Continue) { } in.Files[i] = file } + for i, user := range in.Users { + if user.PasswdFrom != nil && reflect.DeepEqual(user.PasswdFrom, &PasswdSource{}) { + user.PasswdFrom = nil + } + in.Users[i] = user + } } func spokeClusterConfiguration(in *ClusterConfiguration, c randfill.Continue) { diff --git a/api/bootstrap/kubeadm/v1beta1/zz_generated.conversion.go b/api/bootstrap/kubeadm/v1beta1/zz_generated.conversion.go index 4e3b5082e7ec..c4edf2c8fc37 100644 --- a/api/bootstrap/kubeadm/v1beta1/zz_generated.conversion.go +++ b/api/bootstrap/kubeadm/v1beta1/zz_generated.conversion.go @@ -310,16 +310,6 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } - if err := s.AddGeneratedConversionFunc((*User)(nil), (*v1beta2.User)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_v1beta1_User_To_v1beta2_User(a.(*User), b.(*v1beta2.User), scope) - }); err != nil { - return err - } - if err := s.AddGeneratedConversionFunc((*v1beta2.User)(nil), (*User)(nil), func(a, b interface{}, scope conversion.Scope) error { - return Convert_v1beta2_User_To_v1beta1_User(a.(*v1beta2.User), b.(*User), scope) - }); err != nil { - return err - } if err := s.AddConversionFunc((*v1.Condition)(nil), (*corev1beta1.Condition)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1_Condition_To_v1beta1_Condition(a.(*v1.Condition), b.(*corev1beta1.Condition), scope) }); err != nil { @@ -430,6 +420,11 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddConversionFunc((*User)(nil), (*v1beta2.User)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1beta1_User_To_v1beta2_User(a.(*User), b.(*v1beta2.User), scope) + }); err != nil { + return err + } if err := s.AddConversionFunc((*v1beta2.APIServer)(nil), (*APIServer)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1beta2_APIServer_To_v1beta1_APIServer(a.(*v1beta2.APIServer), b.(*APIServer), scope) }); err != nil { @@ -530,6 +525,11 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddConversionFunc((*v1beta2.User)(nil), (*User)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1beta2_User_To_v1beta1_User(a.(*v1beta2.User), b.(*User), scope) + }); err != nil { + return err + } return nil } @@ -1766,7 +1766,7 @@ func autoConvert_v1beta1_User_To_v1beta2_User(in *User, out *v1beta2.User, s con if err := v1.Convert_Pointer_string_To_string(&in.Passwd, &out.Passwd, s); err != nil { return err } - out.PasswdFrom = (*v1beta2.PasswdSource)(unsafe.Pointer(in.PasswdFrom)) + // WARNING: in.PasswdFrom requires manual conversion: inconvertible types (*sigs.k8s.io/cluster-api/api/bootstrap/kubeadm/v1beta1.PasswdSource vs sigs.k8s.io/cluster-api/api/bootstrap/kubeadm/v1beta2.PasswdSource) if err := v1.Convert_Pointer_string_To_string(&in.PrimaryGroup, &out.PrimaryGroup, s); err != nil { return err } @@ -1778,11 +1778,6 @@ func autoConvert_v1beta1_User_To_v1beta2_User(in *User, out *v1beta2.User, s con return nil } -// Convert_v1beta1_User_To_v1beta2_User is an autogenerated conversion function. -func Convert_v1beta1_User_To_v1beta2_User(in *User, out *v1beta2.User, s conversion.Scope) error { - return autoConvert_v1beta1_User_To_v1beta2_User(in, out, s) -} - func autoConvert_v1beta2_User_To_v1beta1_User(in *v1beta2.User, out *User, s conversion.Scope) error { out.Name = in.Name if err := v1.Convert_string_To_Pointer_string(&in.Gecos, &out.Gecos, s); err != nil { @@ -1801,7 +1796,7 @@ func autoConvert_v1beta2_User_To_v1beta1_User(in *v1beta2.User, out *User, s con if err := v1.Convert_string_To_Pointer_string(&in.Passwd, &out.Passwd, s); err != nil { return err } - out.PasswdFrom = (*PasswdSource)(unsafe.Pointer(in.PasswdFrom)) + // WARNING: in.PasswdFrom requires manual conversion: inconvertible types (sigs.k8s.io/cluster-api/api/bootstrap/kubeadm/v1beta2.PasswdSource vs *sigs.k8s.io/cluster-api/api/bootstrap/kubeadm/v1beta1.PasswdSource) if err := v1.Convert_string_To_Pointer_string(&in.PrimaryGroup, &out.PrimaryGroup, s); err != nil { return err } @@ -1812,8 +1807,3 @@ func autoConvert_v1beta2_User_To_v1beta1_User(in *v1beta2.User, out *User, s con out.SSHAuthorizedKeys = *(*[]string)(unsafe.Pointer(&in.SSHAuthorizedKeys)) return nil } - -// Convert_v1beta2_User_To_v1beta1_User is an autogenerated conversion function. -func Convert_v1beta2_User_To_v1beta1_User(in *v1beta2.User, out *User, s conversion.Scope) error { - return autoConvert_v1beta2_User_To_v1beta1_User(in, out, s) -} diff --git a/api/bootstrap/kubeadm/v1beta2/kubeadmconfig_types.go b/api/bootstrap/kubeadm/v1beta2/kubeadmconfig_types.go index ab4f9d810abe..979484424074 100644 --- a/api/bootstrap/kubeadm/v1beta2/kubeadmconfig_types.go +++ b/api/bootstrap/kubeadm/v1beta2/kubeadmconfig_types.go @@ -256,7 +256,7 @@ func (c *KubeadmConfigSpec) validateUsers(pathPrefix *field.Path) field.ErrorLis for i := range c.Users { user := c.Users[i] - if user.Passwd != "" && user.PasswdFrom != nil { + if user.Passwd != "" && user.PasswdFrom.IsDefined() { allErrs = append( allErrs, field.Invalid( @@ -269,7 +269,7 @@ func (c *KubeadmConfigSpec) validateUsers(pathPrefix *field.Path) field.ErrorLis // n.b.: if we ever add types besides Secret as a PasswdFrom // Source, we must add webhook validation here for one of the // sources being non-nil. - if user.PasswdFrom != nil { + if user.PasswdFrom.IsDefined() { if user.PasswdFrom.Secret.Name == "" { allErrs = append( allErrs, @@ -681,6 +681,11 @@ type PasswdSource struct { Secret SecretPasswdSource `json:"secret,omitempty,omitzero"` } +// IsDefined returns true if the PasswdSource is defined. +func (r *PasswdSource) IsDefined() bool { + return !reflect.DeepEqual(r, &PasswdSource{}) +} + // SecretPasswdSource adapts a Secret into a PasswdSource. // // The contents of the target Secret's Data field will be presented @@ -743,7 +748,7 @@ type User struct { // passwdFrom is a referenced source of passwd to populate the passwd. // +optional - PasswdFrom *PasswdSource `json:"passwdFrom,omitempty"` + PasswdFrom PasswdSource `json:"passwdFrom,omitempty,omitzero"` // primaryGroup specifies the primary group for the user // +optional diff --git a/api/bootstrap/kubeadm/v1beta2/zz_generated.deepcopy.go b/api/bootstrap/kubeadm/v1beta2/zz_generated.deepcopy.go index 32268ace66dd..528b6a39f7fa 100644 --- a/api/bootstrap/kubeadm/v1beta2/zz_generated.deepcopy.go +++ b/api/bootstrap/kubeadm/v1beta2/zz_generated.deepcopy.go @@ -1295,11 +1295,7 @@ func (in *User) DeepCopyInto(out *User) { *out = new(bool) **out = **in } - if in.PasswdFrom != nil { - in, out := &in.PasswdFrom, &out.PasswdFrom - *out = new(PasswdSource) - **out = **in - } + out.PasswdFrom = in.PasswdFrom if in.LockPassword != nil { in, out := &in.LockPassword, &out.LockPassword *out = new(bool) diff --git a/api/controlplane/kubeadm/v1beta1/conversion_test.go b/api/controlplane/kubeadm/v1beta1/conversion_test.go index 45335d1f04db..082489b6b164 100644 --- a/api/controlplane/kubeadm/v1beta1/conversion_test.go +++ b/api/controlplane/kubeadm/v1beta1/conversion_test.go @@ -291,6 +291,12 @@ func spokeKubeadmConfigSpec(in *bootstrapv1beta1.KubeadmConfigSpec, c randfill.C } in.Files[i] = file } + for i, user := range in.Users { + if user.PasswdFrom != nil && reflect.DeepEqual(user.PasswdFrom, &bootstrapv1beta1.PasswdSource{}) { + user.PasswdFrom = nil + } + in.Users[i] = user + } } func spokeClusterConfiguration(in *bootstrapv1beta1.ClusterConfiguration, c randfill.Continue) { diff --git a/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go b/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go index ad66a72016b7..d1c7018a6640 100644 --- a/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go +++ b/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller.go @@ -1026,12 +1026,12 @@ func (r *KubeadmConfigReconciler) resolveUsers(ctx context.Context, cfg *bootstr for i := range cfg.Spec.Users { in := cfg.Spec.Users[i] - if in.PasswdFrom != nil { + if in.PasswdFrom.IsDefined() { data, err := r.resolveSecretPasswordContent(ctx, cfg.Namespace, in) if err != nil { return nil, errors.Wrapf(err, "failed to resolve passwd source") } - in.PasswdFrom = nil + in.PasswdFrom = bootstrapv1.PasswdSource{} passwdContent := string(data) in.Passwd = passwdContent } diff --git a/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller_test.go b/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller_test.go index c4e273c33ece..50c708c77b26 100644 --- a/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller_test.go +++ b/bootstrap/kubeadm/internal/controllers/kubeadmconfig_controller_test.go @@ -2445,7 +2445,7 @@ func TestKubeadmConfigReconciler_ResolveUsers(t *testing.T) { Users: []bootstrapv1.User{ { Name: "foo", - PasswdFrom: &bootstrapv1.PasswdSource{ + PasswdFrom: bootstrapv1.PasswdSource{ Secret: bootstrapv1.SecretPasswdSource{ Name: "source", Key: "key", @@ -2473,7 +2473,7 @@ func TestKubeadmConfigReconciler_ResolveUsers(t *testing.T) { }, { Name: "bar", - PasswdFrom: &bootstrapv1.PasswdSource{ + PasswdFrom: bootstrapv1.PasswdSource{ Secret: bootstrapv1.SecretPasswdSource{ Name: "source", Key: "key", @@ -2514,7 +2514,7 @@ func TestKubeadmConfigReconciler_ResolveUsers(t *testing.T) { // from secret still are. passwdFrom := map[string]bool{} for _, user := range tc.cfg.Spec.Users { - if user.PasswdFrom != nil { + if user.PasswdFrom.IsDefined() { passwdFrom[user.Name] = true } } @@ -2524,7 +2524,7 @@ func TestKubeadmConfigReconciler_ResolveUsers(t *testing.T) { g.Expect(users).To(BeComparableTo(tc.expect)) for _, user := range tc.cfg.Spec.Users { if passwdFrom[user.Name] { - g.Expect(user.PasswdFrom).NotTo(BeNil()) + g.Expect(user.PasswdFrom.IsDefined()).To(BeTrue()) g.Expect(user.Passwd).To(BeEmpty()) } } diff --git a/bootstrap/kubeadm/internal/webhooks/kubeadmconfig_test.go b/bootstrap/kubeadm/internal/webhooks/kubeadmconfig_test.go index dec73190b07b..ba53071c950e 100644 --- a/bootstrap/kubeadm/internal/webhooks/kubeadmconfig_test.go +++ b/bootstrap/kubeadm/internal/webhooks/kubeadmconfig_test.go @@ -178,7 +178,7 @@ func TestKubeadmConfigValidate(t *testing.T) { Spec: bootstrapv1.KubeadmConfigSpec{ Users: []bootstrapv1.User{ { - PasswdFrom: &bootstrapv1.PasswdSource{ + PasswdFrom: bootstrapv1.PasswdSource{ Secret: bootstrapv1.SecretPasswdSource{ Name: "foo", Key: "bar", @@ -198,8 +198,12 @@ func TestKubeadmConfigValidate(t *testing.T) { Spec: bootstrapv1.KubeadmConfigSpec{ Users: []bootstrapv1.User{ { - PasswdFrom: &bootstrapv1.PasswdSource{}, - Passwd: "foo", + PasswdFrom: bootstrapv1.PasswdSource{ + Secret: bootstrapv1.SecretPasswdSource{ + Name: "secret", + }, + }, + Passwd: "foo", }, }, }, @@ -215,7 +219,7 @@ func TestKubeadmConfigValidate(t *testing.T) { Spec: bootstrapv1.KubeadmConfigSpec{ Users: []bootstrapv1.User{ { - PasswdFrom: &bootstrapv1.PasswdSource{ + PasswdFrom: bootstrapv1.PasswdSource{ Secret: bootstrapv1.SecretPasswdSource{ Key: "bar", }, @@ -236,7 +240,7 @@ func TestKubeadmConfigValidate(t *testing.T) { Spec: bootstrapv1.KubeadmConfigSpec{ Users: []bootstrapv1.User{ { - PasswdFrom: &bootstrapv1.PasswdSource{ + PasswdFrom: bootstrapv1.PasswdSource{ Secret: bootstrapv1.SecretPasswdSource{ Name: "foo", }, diff --git a/internal/api/bootstrap/kubeadm/v1alpha3/conversion.go b/internal/api/bootstrap/kubeadm/v1alpha3/conversion.go index 11432ff8d5d5..5bfd91efe120 100644 --- a/internal/api/bootstrap/kubeadm/v1alpha3/conversion.go +++ b/internal/api/bootstrap/kubeadm/v1alpha3/conversion.go @@ -86,7 +86,7 @@ func RestoreKubeadmConfigSpec(dst *bootstrapv1.KubeadmConfigSpec, restored *boot dst.Users = restored.Users if restored.Users != nil { for i := range restored.Users { - if restored.Users[i].PasswdFrom != nil { + if restored.Users[i].PasswdFrom.IsDefined() { dst.Users[i].PasswdFrom = restored.Users[i].PasswdFrom } } diff --git a/internal/api/bootstrap/kubeadm/v1alpha4/conversion.go b/internal/api/bootstrap/kubeadm/v1alpha4/conversion.go index c83e0ce45098..e603596551dd 100644 --- a/internal/api/bootstrap/kubeadm/v1alpha4/conversion.go +++ b/internal/api/bootstrap/kubeadm/v1alpha4/conversion.go @@ -86,7 +86,7 @@ func RestoreKubeadmConfigSpec(dst *bootstrapv1.KubeadmConfigSpec, restored *boot dst.Users = restored.Users if restored.Users != nil { for i := range restored.Users { - if restored.Users[i].PasswdFrom != nil { + if restored.Users[i].PasswdFrom.IsDefined() { dst.Users[i].PasswdFrom = restored.Users[i].PasswdFrom } } diff --git a/internal/util/ssa/filterintent.go b/internal/util/ssa/filterintent.go index 28b90f31d4ac..c3c776407d45 100644 --- a/internal/util/ssa/filterintent.go +++ b/internal/util/ssa/filterintent.go @@ -17,7 +17,9 @@ limitations under the License. package ssa import ( + "fmt" "reflect" + "strings" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -82,52 +84,71 @@ func FilterObject(obj *unstructured.Unstructured, input *FilterObjectInput) { // all of them are defined in reconcile_state.go and are targeting well-known fields inside nested maps. // Allowed paths / ignore paths which point to an array are not supported by the current implementation. func FilterIntent(ctx *FilterIntentInput) bool { - value, ok := ctx.Value.(map[string]interface{}) - if !ok { - return false - } - gotDeletions := false - for field := range value { - fieldCtx := &FilterIntentInput{ - // Compose the Path for the nested field. - Path: ctx.Path.Append(field), - // Gets the original and the modified Value for the field. - Value: value[field], - // Carry over global values from the context. - ShouldFilter: ctx.ShouldFilter, - DropEmptyStructAndNil: ctx.DropEmptyStructAndNil, - } - // If the field should be filtered out, delete it from the modified object. - if fieldCtx.ShouldFilter != nil && fieldCtx.ShouldFilter(fieldCtx.Path) { - delete(value, field) - gotDeletions = true - continue - } - - // If empty struct should be dropped and the value is a empty struct, delete it from the modified object. - if fieldCtx.DropEmptyStructAndNil && reflect.DeepEqual(fieldCtx.Value, map[string]interface{}{}) { - delete(value, field) - gotDeletions = true - continue - } - // If nil should be dropped and the value is nil, delete it from the modified object. - if fieldCtx.DropEmptyStructAndNil && reflect.DeepEqual(fieldCtx.Value, nil) { - delete(value, field) - gotDeletions = true - continue - } + switch value := ctx.Value.(type) { + case map[string]interface{}: + for field := range value { + fieldCtx := &FilterIntentInput{ + // Compose the Path for the nested field. + Path: ctx.Path.Append(field), + // Gets the original and the modified Value for the field. + Value: value[field], + // Carry over global values from the context. + ShouldFilter: ctx.ShouldFilter, + DropEmptyStructAndNil: ctx.DropEmptyStructAndNil, + } - // Process nested fields and get in return if FilterIntent removed fields. - if FilterIntent(fieldCtx) { - // Ensure we are not leaving empty maps around. - if v, ok := fieldCtx.Value.(map[string]interface{}); ok && len(v) == 0 { + // If the field should be filtered out, delete it from the modified object. + if fieldCtx.ShouldFilter != nil && fieldCtx.ShouldFilter(fieldCtx.Path) { delete(value, field) gotDeletions = true + continue + } + + // TODO: Can be removed once we bumped to k8s.io v0.34 because the DefaultUnstructuredConverter will then handle omitzero + if strings.HasPrefix(fieldCtx.Path.String(), "spec") && fieldCtx.DropEmptyStructAndNil { + // If empty struct should be dropped and the value is a empty struct, delete it from the modified object. + if reflect.DeepEqual(fieldCtx.Value, map[string]interface{}{}) { + delete(value, field) + gotDeletions = true + continue + } + // If nil should be dropped and the value is nil, delete it from the modified object. + if reflect.DeepEqual(fieldCtx.Value, nil) { + delete(value, field) + gotDeletions = true + continue + } + } + + // Process nested fields and get in return if FilterIntent removed fields. + if FilterIntent(fieldCtx) { + gotDeletions = true + // Ensure we are not leaving empty maps around. + if v, ok := fieldCtx.Value.(map[string]interface{}); ok && len(v) == 0 { + delete(value, field) + } + } + } + case []interface{}: + // TODO: Can be removed once we bumped to k8s.io v0.34 because the DefaultUnstructuredConverter will then handle omitzero + if strings.HasPrefix(ctx.Path.String(), "spec") && ctx.DropEmptyStructAndNil { + for i, v := range value { + fieldCtx := &FilterIntentInput{ + // Compose the Path for the nested field. + Path: ctx.Path.Append(fmt.Sprintf("[%d]", i)), + // Not supporting ShouldFilter within arrays, so not setting it. + Value: v, + DropEmptyStructAndNil: ctx.DropEmptyStructAndNil, + } + if FilterIntent(fieldCtx) { + gotDeletions = true + } } } } + return gotDeletions } diff --git a/internal/util/ssa/filterintent_test.go b/internal/util/ssa/filterintent_test.go index f336495cd6b7..ea078c08f7ed 100644 --- a/internal/util/ssa/filterintent_test.go +++ b/internal/util/ssa/filterintent_test.go @@ -251,6 +251,66 @@ func Test_filterDropEmptyStructAndNil(t *testing.T) { // we are filtering out spec.bar.field and then spec.bar and spec given that they are empty maps }, }, + { + name: "Cleanup fields in arrays and don't cleanup empty leaf arrays", + ctx: &FilterIntentInput{ + Path: contract.Path{}, + Value: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "joinConfiguration": map[string]interface{}{ + "nodeRegistration": map[string]interface{}{ + "taints": []interface{}{}, // should be preserved + "kubeletExtraArgs": []interface{}{ + map[string]interface{}{ + "name": "eviction-hard", + "value": "nodefs.available<0%,nodefs.inodesFree<0%,imagefs.available<0%", + }, + }, + }, + "discovery": map[string]interface{}{}, // should be cleaned up + }, + "users": []interface{}{ + map[string]interface{}{ + "name": "default-user", + "passwdFrom": map[string]interface{}{ // should be cleaned up + "secret": map[string]interface{}{}}, + }, + }, + }, + }, + }, + }, + DropEmptyStructAndNil: true, + }, + wantValue: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "joinConfiguration": map[string]interface{}{ + "nodeRegistration": map[string]interface{}{ + "taints": []interface{}{}, // taints was preserved + "kubeletExtraArgs": []interface{}{ + map[string]interface{}{ + "name": "eviction-hard", + "value": "nodefs.available<0%,nodefs.inodesFree<0%,imagefs.available<0%", + }, + }, + }, + // discovery was cleaned up + }, + "users": []interface{}{ + map[string]interface{}{ + "name": "default-user", + // passwdFrom was cleaned up + }, + }, + }, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {