From 1cc8c360521043507a6342de6ad01d102a8bdff0 Mon Sep 17 00:00:00 2001 From: Malte Poll Date: Fri, 1 Jul 2022 10:40:54 +0200 Subject: [PATCH] [node operator] NodeImage controller unit test Signed-off-by: Malte Poll --- .../controllers/nodeimage_controller_test.go | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 operators/constellation-node-operator/controllers/nodeimage_controller_test.go diff --git a/operators/constellation-node-operator/controllers/nodeimage_controller_test.go b/operators/constellation-node-operator/controllers/nodeimage_controller_test.go new file mode 100644 index 000000000..e2db6d11e --- /dev/null +++ b/operators/constellation-node-operator/controllers/nodeimage_controller_test.go @@ -0,0 +1,718 @@ +package controllers + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + updatev1alpha1 "github.com/edgelesssys/constellation/operators/constellation-node-operator/api/v1alpha1" +) + +func TestAnnotateNodes(t *testing.T) { + testCases := map[string]struct { + getScalingGroupErr error + getNodeImageErr error + patchErr error + node corev1.Node + nodeAfterPatch corev1.Node + wantAnnotated *corev1.Node + }{ + "node is not annotated": { + node: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + }, + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + nodeAfterPatch: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + nodeImageAnnotation: "node-image", + }, + }, + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + wantAnnotated: &corev1.Node{ + TypeMeta: metav1.TypeMeta{ + Kind: "Node", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + nodeImageAnnotation: "node-image", + }, + }, + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + }, + "node is already annotated": { + node: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + nodeImageAnnotation: "node-image", + }, + }, + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + wantAnnotated: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + nodeImageAnnotation: "node-image", + }, + }, + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + }, + "node is missing providerID": {}, + "unable to retrieve scaling group": { + getScalingGroupErr: errors.New("error"), + node: corev1.Node{ + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + }, + "unable to retrieve node image": { + getNodeImageErr: errors.New("error"), + node: corev1.Node{ + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + }, + "unable to patch node annotations": { + patchErr: errors.New("error"), + node: corev1.Node{ + Spec: corev1.NodeSpec{ProviderID: "provider-id"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + reconciler := NodeImageReconciler{ + nodeReplacer: &stubNodeReplacerReader{ + nodeImage: "node-image", + scalingGroupID: "scaling-group-id", + scalingGroupIDErr: tc.getScalingGroupErr, + nodeImageErr: tc.getNodeImageErr, + }, + Client: &stubReadWriterClient{ + stubReaderClient: *newStubReaderClient(t, []runtime.Object{&tc.nodeAfterPatch}, nil, nil), + stubWriterClient: stubWriterClient{ + patchErr: tc.patchErr, + }, + }, + } + annotated, invalid := reconciler.annotateNodes(context.Background(), []corev1.Node{tc.node}) + if tc.wantAnnotated == nil { + assert.Len(annotated, 0) + assert.Len(invalid, 1) + return + } + + assert.Len(annotated, 1) + assert.Len(invalid, 0) + assert.Equal(*tc.wantAnnotated, annotated[0]) + }) + } +} + +func TestPairDonorsAndHeirs(t *testing.T) { + testCases := map[string]struct { + outdatedNode corev1.Node + mintNode mintNode + wantPair *replacementPair + }{ + "nodes have same scaling group": { + outdatedNode: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "outdated-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + }, + }, + }, + mintNode: mintNode{ + pendingNode: updatev1alpha1.PendingNode{ + Spec: updatev1alpha1.PendingNodeSpec{ + ScalingGroupID: "scaling-group-id", + }, + }, + node: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mint-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + }, + }, + }, + }, + wantPair: &replacementPair{ + donor: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "outdated-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + heirAnnotation: "mint-name", + }, + }, + }, + heir: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mint-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + donorAnnotation: "outdated-name", + }, + }, + }, + }, + }, + "nodes have different scaling groups": { + outdatedNode: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "outdated-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-1", + }, + }, + }, + mintNode: mintNode{ + pendingNode: updatev1alpha1.PendingNode{ + Spec: updatev1alpha1.PendingNodeSpec{ + ScalingGroupID: "scaling-group-2", + }, + }, + node: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mint-name", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-2", + }, + }, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + reconciler := NodeImageReconciler{ + nodeReplacer: &stubNodeReplacerReader{}, + Client: &stubReadWriterClient{ + stubReaderClient: *newStubReaderClient(t, []runtime.Object{&tc.outdatedNode, &tc.mintNode.node}, nil, nil), + }, + } + nodeImage := updatev1alpha1.NodeImage{} + pairs := reconciler.pairDonorsAndHeirs(context.Background(), &nodeImage, []corev1.Node{tc.outdatedNode}, []mintNode{tc.mintNode}) + if tc.wantPair == nil { + assert.Len(pairs, 0) + return + } + + assert.Len(pairs, 1) + assert.Equal(*tc.wantPair, pairs[0]) + }) + } +} + +func TestMatchDonorsAndHeirs(t *testing.T) { + testCases := map[string]struct { + donor, heir corev1.Node + wantPair *replacementPair + }{ + "nodes match": { + donor: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "donor", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + heirAnnotation: "heir", + }, + }, + }, + heir: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "heir", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + donorAnnotation: "donor", + }, + }, + }, + wantPair: &replacementPair{ + donor: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "donor", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + heirAnnotation: "heir", + }, + }, + }, + heir: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "heir", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + donorAnnotation: "donor", + }, + }, + }, + }, + }, + "nodes do not match": { + donor: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "donor", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + heirAnnotation: "other-heir", + }, + }, + }, + heir: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "heir", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group-id", + donorAnnotation: "other-donor", + }, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + reconciler := NodeImageReconciler{ + nodeReplacer: &stubNodeReplacerReader{}, + Client: &stubReadWriterClient{ + stubReaderClient: *newStubReaderClient(t, []runtime.Object{&tc.donor, &tc.heir}, nil, nil), + }, + } + pairs := reconciler.matchDonorsAndHeirs(context.Background(), nil, []corev1.Node{tc.donor}, []corev1.Node{tc.heir}) + if tc.wantPair == nil { + assert.Len(pairs, 0) + return + } + + assert.Len(pairs, 1) + assert.Equal(*tc.wantPair, pairs[0]) + }) + } +} + +func TestCreateNewNodes(t *testing.T) { + testCases := map[string]struct { + outdatedNodes []corev1.Node + pendingNodes []updatev1alpha1.PendingNode + scalingGroupByID map[string]updatev1alpha1.ScalingGroup + budget int + wantCreateCalls []string + }{ + "no outdated nodes": { + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 1, + }, + "single outdated node": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 1, + wantCreateCalls: []string{"scaling-group"}, + }, + "budget larger than needed": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 2, + wantCreateCalls: []string{"scaling-group"}, + }, + "no budget": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + }, + "scaling group image is outdated": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "outdated-image", + }, + }, + }, + budget: 1, + }, + "pending node exists": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + pendingNodes: []updatev1alpha1.PendingNode{ + { + Spec: updatev1alpha1.PendingNodeSpec{ + Goal: updatev1alpha1.NodeGoalJoin, + ScalingGroupID: "scaling-group", + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 1, + }, + "leaving pending node is ignored": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + pendingNodes: []updatev1alpha1.PendingNode{ + { + Spec: updatev1alpha1.PendingNodeSpec{ + Goal: updatev1alpha1.NodeGoalLeave, + ScalingGroupID: "scaling-group", + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 1, + wantCreateCalls: []string{"scaling-group"}, + }, + "freshly chosen donor node is skipped": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + heirAnnotation: "heir", + }, + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 1, + }, + "scaling group exists without outdated nodes": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + scalingGroupByID: map[string]updatev1alpha1.ScalingGroup{ + "scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + "other-scaling-group": { + Status: updatev1alpha1.ScalingGroupStatus{ + ImageReference: "image", + }, + }, + }, + budget: 2, + wantCreateCalls: []string{"scaling-group"}, + }, + "scaling group does not exist": { + outdatedNodes: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + Annotations: map[string]string{ + scalingGroupAnnotation: "scaling-group", + }, + }, + }, + }, + budget: 1, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + desiredNodeImage := updatev1alpha1.NodeImage{ + Spec: updatev1alpha1.NodeImageSpec{ + ImageReference: "image", + }, + } + reconciler := NodeImageReconciler{ + nodeReplacer: &stubNodeReplacerWriter{}, + Client: &stubReadWriterClient{ + stubReaderClient: *newStubReaderClient(t, []runtime.Object{}, nil, nil), + }, + Scheme: getScheme(t), + } + err := reconciler.createNewNodes(context.Background(), desiredNodeImage, tc.outdatedNodes, tc.pendingNodes, tc.scalingGroupByID, tc.budget) + require.NoError(err) + assert.Equal(tc.wantCreateCalls, reconciler.nodeReplacer.(*stubNodeReplacerWriter).createCalls) + }) + } +} + +func TestGroupNodes(t *testing.T) { + latestImageReference := "latest-image" + scalingGroup := "scaling-group" + wantNodeGroups := nodeGroups{ + Outdated: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "outdated", + Annotations: map[string]string{ + scalingGroupAnnotation: scalingGroup, + nodeImageAnnotation: "old-image", + }, + }, + }, + }, + UpToDate: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "uptodate", + Annotations: map[string]string{ + scalingGroupAnnotation: scalingGroup, + nodeImageAnnotation: latestImageReference, + }, + }, + }, + }, + Donors: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "donor", + Annotations: map[string]string{ + scalingGroupAnnotation: scalingGroup, + nodeImageAnnotation: "old-image", + heirAnnotation: "heir", + }, + }, + }, + }, + Heirs: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "heir", + Annotations: map[string]string{ + scalingGroupAnnotation: scalingGroup, + nodeImageAnnotation: latestImageReference, + donorAnnotation: "donor", + }, + }, + }, + }, + Obsolete: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "obsolete", + Annotations: map[string]string{ + scalingGroupAnnotation: scalingGroup, + nodeImageAnnotation: latestImageReference, + obsoleteAnnotation: "true", + }, + }, + }, + }, + Mint: []mintNode{ + { + node: corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mint", + Annotations: map[string]string{ + scalingGroupAnnotation: scalingGroup, + nodeImageAnnotation: latestImageReference, + }, + }, + }, + pendingNode: updatev1alpha1.PendingNode{ + Spec: updatev1alpha1.PendingNodeSpec{ + NodeName: "mint", + Goal: updatev1alpha1.NodeGoalJoin, + }, + Status: updatev1alpha1.PendingNodeStatus{ + CSPNodeState: updatev1alpha1.NodeStateReady, + }, + }, + }, + }, + } + nodes := []corev1.Node{} + nodes = append(nodes, wantNodeGroups.Outdated...) + nodes = append(nodes, wantNodeGroups.UpToDate...) + nodes = append(nodes, wantNodeGroups.Donors...) + nodes = append(nodes, wantNodeGroups.Heirs...) + nodes = append(nodes, wantNodeGroups.Obsolete...) + nodes = append(nodes, wantNodeGroups.Mint[0].node) + pendingNodes := []updatev1alpha1.PendingNode{ + wantNodeGroups.Mint[0].pendingNode, + } + + assert := assert.New(t) + groups := groupNodes(nodes, pendingNodes, latestImageReference) + assert.Equal(wantNodeGroups, groups) +} + +type stubNodeReplacerReader struct { + nodeImage string + scalingGroupID string + nodeImageErr error + scalingGroupIDErr error + unimplementedNodeReplacer +} + +func (r *stubNodeReplacerReader) GetNodeImage(ctx context.Context, providerID string) (string, error) { + return r.nodeImage, r.nodeImageErr +} + +func (r *stubNodeReplacerReader) GetScalingGroupID(ctx context.Context, providerID string) (string, error) { + return r.scalingGroupID, r.scalingGroupIDErr +} + +type stubNodeReplacerWriter struct { + createNodeName string + createProviderID string + createErr error + deleteErr error + + createCalls []string + deleteCalls []string + + unimplementedNodeReplacer +} + +func (r *stubNodeReplacerWriter) CreateNode(ctx context.Context, scalingGroupID string) (nodeName, providerID string, err error) { + r.createCalls = append(r.createCalls, scalingGroupID) + return r.createNodeName, r.createProviderID, r.createErr +} + +func (r *stubNodeReplacerWriter) DeleteNode(ctx context.Context, providerID string) error { + r.deleteCalls = append(r.deleteCalls, providerID) + return r.deleteErr +} + +type unimplementedNodeReplacer struct{} + +func (*unimplementedNodeReplacer) GetNodeImage(ctx context.Context, providerID string) (string, error) { + panic("unimplemented") +} + +func (*unimplementedNodeReplacer) GetScalingGroupID(ctx context.Context, providerID string) (string, error) { + panic("unimplemented") +} + +func (*unimplementedNodeReplacer) CreateNode(ctx context.Context, scalingGroupID string) (nodeName, providerID string, err error) { + panic("unimplemented") +} + +func (*unimplementedNodeReplacer) DeleteNode(ctx context.Context, providerID string) error { + panic("unimplemented") +}