sigs.k8s.io/cluster-api@v1.7.1/cmd/clusterctl/client/cluster/topology_test.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package cluster
    18  
    19  import (
    20  	"context"
    21  	_ "embed"
    22  	"fmt"
    23  	"strings"
    24  	"testing"
    25  
    26  	. "github.com/onsi/gomega"
    27  	"github.com/onsi/gomega/types"
    28  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    29  	"sigs.k8s.io/controller-runtime/pkg/client"
    30  
    31  	"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test"
    32  	utilyaml "sigs.k8s.io/cluster-api/util/yaml"
    33  )
    34  
    35  var (
    36  	//go:embed assets/topology-test/new-clusterclass-and-cluster.yaml
    37  	newClusterClassAndClusterYAML []byte
    38  
    39  	//go:embed assets/topology-test/mock-CRDs.yaml
    40  	mockCRDsYAML []byte
    41  
    42  	//go:embed assets/topology-test/my-cluster-class.yaml
    43  	existingMyClusterClassYAML []byte
    44  
    45  	//go:embed assets/topology-test/existing-my-cluster.yaml
    46  	existingMyClusterYAML []byte
    47  
    48  	//go:embed assets/topology-test/existing-my-second-cluster.yaml
    49  	existingMySecondClusterYAML []byte
    50  
    51  	// modifiedClusterYAML changes the control plane replicas from 1 to 3.
    52  	//go:embed assets/topology-test/modified-my-cluster.yaml
    53  	modifiedMyClusterYAML []byte
    54  
    55  	// modifiedDockerMachineTemplateYAML adds metadat to the docker machine used by the control plane template..
    56  	//go:embed assets/topology-test/modified-CP-dockermachinetemplate.yaml
    57  	modifiedDockerMachineTemplateYAML []byte
    58  
    59  	//go:embed assets/topology-test/objects-in-different-namespaces.yaml
    60  	objsInDifferentNamespacesYAML []byte
    61  )
    62  
    63  func Test_topologyClient_Plan(t *testing.T) {
    64  	type args struct {
    65  		in *TopologyPlanInput
    66  	}
    67  	type item struct {
    68  		kind       string
    69  		namespace  string
    70  		namePrefix string
    71  	}
    72  	type out struct {
    73  		affectedClusters       []client.ObjectKey
    74  		affectedClusterClasses []client.ObjectKey
    75  		reconciledCluster      *client.ObjectKey
    76  		created                []item
    77  		modified               []item
    78  		deleted                []item
    79  	}
    80  	tests := []struct {
    81  		name            string
    82  		existingObjects []*unstructured.Unstructured
    83  		args            args
    84  		want            out
    85  		wantErr         bool
    86  	}{
    87  		{
    88  			name: "Input with new ClusterClass and new Cluster",
    89  			args: args{
    90  				in: &TopologyPlanInput{
    91  					Objs: mustToUnstructured(newClusterClassAndClusterYAML),
    92  				},
    93  			},
    94  			want: out{
    95  				created: []item{
    96  					{kind: "DockerCluster", namespace: "default", namePrefix: "my-cluster-"},
    97  					{kind: "DockerMachineTemplate", namespace: "default", namePrefix: "my-cluster-md-0-"},
    98  					{kind: "DockerMachineTemplate", namespace: "default", namePrefix: "my-cluster-md-1-"},
    99  					{kind: "DockerMachineTemplate", namespace: "default", namePrefix: "my-cluster-"},
   100  					{kind: "KubeadmConfigTemplate", namespace: "default", namePrefix: "my-cluster-md-0-"},
   101  					{kind: "KubeadmConfigTemplate", namespace: "default", namePrefix: "my-cluster-md-1-"},
   102  					{kind: "KubeadmControlPlane", namespace: "default", namePrefix: "my-cluster-"},
   103  					{kind: "MachineDeployment", namespace: "default", namePrefix: "my-cluster-md-0-"},
   104  					{kind: "MachineDeployment", namespace: "default", namePrefix: "my-cluster-md-1-"},
   105  				},
   106  				modified: []item{
   107  					{kind: "Cluster", namespace: "default", namePrefix: "my-cluster"},
   108  				},
   109  				affectedClusters: func() []client.ObjectKey {
   110  					cluster := client.ObjectKey{Namespace: "default", Name: "my-cluster"}
   111  					return []client.ObjectKey{cluster}
   112  				}(),
   113  				affectedClusterClasses: func() []client.ObjectKey {
   114  					cc := client.ObjectKey{Namespace: "default", Name: "my-cluster-class"}
   115  					return []client.ObjectKey{cc}
   116  				}(),
   117  				reconciledCluster: &client.ObjectKey{Namespace: "default", Name: "my-cluster"},
   118  			},
   119  			wantErr: false,
   120  		},
   121  		{
   122  			name: "Modifying an existing Cluster",
   123  			existingObjects: mustToUnstructured(
   124  				mockCRDsYAML,
   125  				existingMyClusterClassYAML,
   126  				existingMyClusterYAML,
   127  			),
   128  			args: args{
   129  				in: &TopologyPlanInput{
   130  					Objs: mustToUnstructured(modifiedMyClusterYAML),
   131  				},
   132  			},
   133  			want: out{
   134  				affectedClusters: func() []client.ObjectKey {
   135  					cluster := client.ObjectKey{Namespace: "default", Name: "my-cluster"}
   136  					return []client.ObjectKey{cluster}
   137  				}(),
   138  				affectedClusterClasses: []client.ObjectKey{},
   139  				modified: []item{
   140  					{kind: "KubeadmControlPlane", namespace: "default", namePrefix: "my-cluster-"},
   141  				},
   142  				reconciledCluster: &client.ObjectKey{Namespace: "default", Name: "my-cluster"},
   143  			},
   144  			wantErr: false,
   145  		},
   146  		{
   147  			name: "Modifying an existing DockerMachineTemplate. Template used by Control Plane of an existing Cluster.",
   148  			existingObjects: mustToUnstructured(
   149  				mockCRDsYAML,
   150  				existingMyClusterClassYAML,
   151  				existingMyClusterYAML,
   152  			),
   153  			args: args{
   154  				in: &TopologyPlanInput{
   155  					Objs: mustToUnstructured(modifiedDockerMachineTemplateYAML),
   156  				},
   157  			},
   158  			want: out{
   159  				affectedClusters: func() []client.ObjectKey {
   160  					cluster := client.ObjectKey{Namespace: "default", Name: "my-cluster"}
   161  					return []client.ObjectKey{cluster}
   162  				}(),
   163  				affectedClusterClasses: func() []client.ObjectKey {
   164  					cc := client.ObjectKey{Namespace: "default", Name: "my-cluster-class"}
   165  					return []client.ObjectKey{cc}
   166  				}(),
   167  				modified: []item{
   168  					{kind: "KubeadmControlPlane", namespace: "default", namePrefix: "my-cluster-"},
   169  				},
   170  				created: []item{
   171  					// Modifying the DockerClusterTemplate will result in template rotation. A new template will be created
   172  					// and used by KCP.
   173  					{kind: "DockerMachineTemplate", namespace: "default", namePrefix: "my-cluster-"},
   174  				},
   175  				reconciledCluster: &client.ObjectKey{Namespace: "default", Name: "my-cluster"},
   176  			},
   177  			wantErr: false,
   178  		},
   179  		{
   180  			name: "Modifying an existing DockerMachineTemplate. Affects multiple clusters. Target Cluster not specified.",
   181  			existingObjects: mustToUnstructured(
   182  				mockCRDsYAML,
   183  				existingMyClusterClassYAML,
   184  				existingMyClusterYAML,
   185  				existingMySecondClusterYAML,
   186  			),
   187  			args: args{
   188  				in: &TopologyPlanInput{
   189  					Objs: mustToUnstructured(modifiedDockerMachineTemplateYAML),
   190  				},
   191  			},
   192  			want: out{
   193  				affectedClusters: func() []client.ObjectKey {
   194  					cluster := client.ObjectKey{Namespace: "default", Name: "my-cluster"}
   195  					cluster2 := client.ObjectKey{Namespace: "default", Name: "my-second-cluster"}
   196  					return []client.ObjectKey{cluster, cluster2}
   197  				}(),
   198  				affectedClusterClasses: func() []client.ObjectKey {
   199  					cc := client.ObjectKey{Namespace: "default", Name: "my-cluster-class"}
   200  					return []client.ObjectKey{cc}
   201  				}(),
   202  				modified:          []item{},
   203  				created:           []item{},
   204  				reconciledCluster: nil,
   205  			},
   206  			wantErr: false,
   207  		},
   208  		{
   209  			name: "Modifying an existing DockerMachineTemplate. Affects multiple clusters. Target Cluster specified.",
   210  			existingObjects: mustToUnstructured(
   211  				mockCRDsYAML,
   212  				existingMyClusterClassYAML,
   213  				existingMyClusterYAML,
   214  				existingMySecondClusterYAML,
   215  			),
   216  			args: args{
   217  				in: &TopologyPlanInput{
   218  					Objs:              mustToUnstructured(modifiedDockerMachineTemplateYAML),
   219  					TargetClusterName: "my-cluster",
   220  				},
   221  			},
   222  			want: out{
   223  				affectedClusters: func() []client.ObjectKey {
   224  					cluster := client.ObjectKey{Namespace: "default", Name: "my-cluster"}
   225  					cluster2 := client.ObjectKey{Namespace: "default", Name: "my-second-cluster"}
   226  					return []client.ObjectKey{cluster, cluster2}
   227  				}(),
   228  				affectedClusterClasses: func() []client.ObjectKey {
   229  					cc := client.ObjectKey{Namespace: "default", Name: "my-cluster-class"}
   230  					return []client.ObjectKey{cc}
   231  				}(),
   232  				modified: []item{
   233  					{kind: "KubeadmControlPlane", namespace: "default", namePrefix: "my-cluster-"},
   234  				},
   235  				created: []item{
   236  					// Modifying the DockerClusterTemplate will result in template rotation. A new template will be created
   237  					// and used by KCP.
   238  					{kind: "DockerMachineTemplate", namespace: "default", namePrefix: "my-cluster-"},
   239  				},
   240  				reconciledCluster: &client.ObjectKey{Namespace: "default", Name: "my-cluster"},
   241  			},
   242  			wantErr: false,
   243  		},
   244  		{
   245  			name: "Input with objects in different namespaces should return error",
   246  			args: args{
   247  				in: &TopologyPlanInput{
   248  					Objs: mustToUnstructured(objsInDifferentNamespacesYAML),
   249  				},
   250  			},
   251  			wantErr: true,
   252  		},
   253  		{
   254  			name: "Input with TargetNamespace different from objects in input should return error",
   255  			args: args{
   256  				in: &TopologyPlanInput{
   257  					Objs:            mustToUnstructured(newClusterClassAndClusterYAML),
   258  					TargetNamespace: "different-namespace",
   259  				},
   260  			},
   261  			wantErr: true,
   262  		},
   263  	}
   264  	for _, tt := range tests {
   265  		t.Run(tt.name, func(t *testing.T) {
   266  			g := NewWithT(t)
   267  
   268  			ctx := context.Background()
   269  
   270  			existingObjects := []client.Object{}
   271  			for _, o := range tt.existingObjects {
   272  				existingObjects = append(existingObjects, o)
   273  			}
   274  			proxy := test.NewFakeProxy().WithClusterAvailable(true).WithFakeCAPISetup().WithObjs(existingObjects...)
   275  			inventoryClient := newInventoryClient(proxy, nil)
   276  			tc := newTopologyClient(
   277  				proxy,
   278  				inventoryClient,
   279  			)
   280  
   281  			res, err := tc.Plan(ctx, tt.args.in)
   282  			if tt.wantErr {
   283  				g.Expect(err).To(HaveOccurred())
   284  				return
   285  			}
   286  			// The plan should function should not return any error.
   287  			g.Expect(err).ToNot(HaveOccurred())
   288  
   289  			// Check affected ClusterClasses.
   290  			g.Expect(res.ClusterClasses).To(HaveLen(len(tt.want.affectedClusterClasses)))
   291  			for _, cc := range tt.want.affectedClusterClasses {
   292  				g.Expect(res.ClusterClasses).To(ContainElement(cc))
   293  			}
   294  
   295  			// Check affected Clusters.
   296  			g.Expect(res.Clusters).To(HaveLen(len(tt.want.affectedClusters)))
   297  			for _, cluster := range tt.want.affectedClusters {
   298  				g.Expect(res.Clusters).To(ContainElement(cluster))
   299  			}
   300  
   301  			// Check the reconciled cluster.
   302  			if tt.want.reconciledCluster == nil {
   303  				g.Expect(res.ReconciledCluster).To(BeNil())
   304  			} else {
   305  				g.Expect(res.ReconciledCluster).NotTo(BeNil())
   306  				g.Expect(*res.ReconciledCluster).To(BeComparableTo(*tt.want.reconciledCluster))
   307  			}
   308  
   309  			// Check the created objects.
   310  			for _, created := range tt.want.created {
   311  				g.Expect(res.Created).To(ContainElement(MatchTopologyPlanOutputItem(created.kind, created.namespace, created.namePrefix)))
   312  			}
   313  
   314  			// Check the modified objects.
   315  			actualModifiedObjs := []*unstructured.Unstructured{}
   316  			for _, m := range res.Modified {
   317  				actualModifiedObjs = append(actualModifiedObjs, m.After)
   318  			}
   319  			for _, modified := range tt.want.modified {
   320  				g.Expect(actualModifiedObjs).To(ContainElement(MatchTopologyPlanOutputItem(modified.kind, modified.namespace, modified.namePrefix)))
   321  			}
   322  
   323  			// Check the deleted objects.
   324  			for _, deleted := range tt.want.deleted {
   325  				g.Expect(res.Deleted).To(ContainElement(MatchTopologyPlanOutputItem(deleted.kind, deleted.namespace, deleted.namePrefix)))
   326  			}
   327  		})
   328  	}
   329  }
   330  
   331  func MatchTopologyPlanOutputItem(kind, namespace, namePrefix string) types.GomegaMatcher {
   332  	return &topologyPlanOutputItemMatcher{kind, namespace, namePrefix}
   333  }
   334  
   335  type topologyPlanOutputItemMatcher struct {
   336  	kind       string
   337  	namespace  string
   338  	namePrefix string
   339  }
   340  
   341  func (m *topologyPlanOutputItemMatcher) Match(actual interface{}) (bool, error) {
   342  	obj := actual.(*unstructured.Unstructured)
   343  	if obj.GetKind() != m.kind {
   344  		return false, nil
   345  	}
   346  	if obj.GetNamespace() != m.namespace {
   347  		return false, nil
   348  	}
   349  	if !strings.HasPrefix(obj.GetName(), m.namePrefix) {
   350  		return false, nil
   351  	}
   352  	return true, nil
   353  }
   354  
   355  func (m *topologyPlanOutputItemMatcher) FailureMessage(_ interface{}) string {
   356  	return fmt.Sprintf("Expected item Kind=%s, Namespace=%s, Name(prefix)=%s to be present", m.kind, m.namespace, m.namePrefix)
   357  }
   358  
   359  func (m *topologyPlanOutputItemMatcher) NegatedFailureMessage(_ interface{}) string {
   360  	return fmt.Sprintf("Expected item Kind=%s, Namespace=%s, Name(prefix)=%s not to be present", m.kind, m.namespace, m.namePrefix)
   361  }
   362  
   363  func convertToPtrSlice(objs []unstructured.Unstructured) []*unstructured.Unstructured {
   364  	res := []*unstructured.Unstructured{}
   365  	for i := range objs {
   366  		res = append(res, &objs[i])
   367  	}
   368  	return res
   369  }
   370  
   371  func mustToUnstructured(rawyamls ...[]byte) []*unstructured.Unstructured {
   372  	objects := []unstructured.Unstructured{}
   373  	for _, raw := range rawyamls {
   374  		objs, err := utilyaml.ToUnstructured(raw)
   375  		if err != nil {
   376  			panic(err)
   377  		}
   378  		objects = append(objects, objs...)
   379  	}
   380  	return convertToPtrSlice(objects)
   381  }