diff --git a/internal/controller/reconcile_kubernetes_test.go b/internal/controller/reconcile_kubernetes_test.go new file mode 100644 index 0000000..df1e803 --- /dev/null +++ b/internal/controller/reconcile_kubernetes_test.go @@ -0,0 +1,235 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + lifecyclev1alpha1 "github.com/suse-edge/upgrade-controller/api/v1alpha1" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +func TestIsKubernetesUpgraded(t *testing.T) { + const kubernetesVersion = "v1.30.3+k3s1" + + nodeLabels := map[string]string{ + "node-x": "z", + } + + tests := []struct { + name string + nodes *corev1.NodeList + selector labels.Selector + expectedUpgrade bool + }{ + { + name: "All matching nodes upgraded", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+k3s1"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: true, + }, + { + name: "Unschedulable matching node", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: true}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+k3s1"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: false, + }, + { + name: "Not ready matching node", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionFalse}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+k3s1"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: false, + }, + { + name: "Matching node on older Kubernetes version", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+k3s1"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3+k3s1"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+k3s1"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expectedUpgrade, isKubernetesUpgraded(test.nodes, test.selector, kubernetesVersion)) + }) + } +} + +func TestControlPlaneOnlyCluster(t *testing.T) { + assert.True(t, controlPlaneOnlyCluster(&corev1.NodeList{ + Items: []corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"node-role.kubernetes.io/control-plane": "true"}}}, + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"node-role.kubernetes.io/control-plane": "true"}}}, + }, + })) + + assert.False(t, controlPlaneOnlyCluster(&corev1.NodeList{ + Items: []corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"node-role.kubernetes.io/control-plane": "true"}}}, + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"node-role.kubernetes.io/control-plane": "false"}}}, + }, + })) + + assert.False(t, controlPlaneOnlyCluster(&corev1.NodeList{ + Items: []corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"node-role.kubernetes.io/control-plane": "true"}}}, + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + }, + })) + + assert.False(t, controlPlaneOnlyCluster(&corev1.NodeList{ + Items: []corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + }, + })) +} + +func TestTargetKubernetesVersion(t *testing.T) { + kubernetes := &lifecyclev1alpha1.Kubernetes{ + K3S: lifecyclev1alpha1.KubernetesDistribution{ + Version: "v1.30.3+k3s1", + }, + RKE2: lifecyclev1alpha1.KubernetesDistribution{ + Version: "v1.30.3+rke2r1", + }, + } + + tests := []struct { + name string + nodes *corev1.NodeList + expectedVersion string + expectedError string + }{ + { + name: "Empty node list", + nodes: &corev1.NodeList{}, + expectedError: "unable to determine current kubernetes version due to empty node list", + }, + { + name: "Unsupported Kubernetes version", + nodes: &corev1.NodeList{ + Items: []corev1.Node{{Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.3"}}}}, + }, + expectedError: "upgrading from kubernetes version v1.30.3 is not supported", + }, + { + name: "Target k3s version", + nodes: &corev1.NodeList{ + Items: []corev1.Node{{Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+k3s1"}}}}, + }, + expectedVersion: "v1.30.3+k3s1", + }, + { + name: "Target RKE2 version", + nodes: &corev1.NodeList{ + Items: []corev1.Node{{Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.28.12+rke2r1"}}}}, + }, + expectedVersion: "v1.30.3+rke2r1", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + version, err := targetKubernetesVersion(test.nodes, kubernetes) + if test.expectedError != "" { + require.Error(t, err) + assert.EqualError(t, err, test.expectedError) + assert.Empty(t, version) + } else { + require.NoError(t, err) + assert.Equal(t, test.expectedVersion, version) + } + }) + } +} diff --git a/internal/controller/reconcile_os.go b/internal/controller/reconcile_os.go index b94c0ce..b196297 100644 --- a/internal/controller/reconcile_os.go +++ b/internal/controller/reconcile_os.go @@ -129,7 +129,7 @@ func validateOSArch(nodeList *corev1.NodeList, supportedArchs []lifecyclev1alpha for _, node := range nodeList.Items { nodeArch := node.Status.NodeInfo.Architecture if _, ok := supportedArchMap[nodeArch]; !ok { - return fmt.Errorf("unsuported arch '%s' for '%s' node. Supported archs: %s", nodeArch, node.Name, supportedArchs) + return fmt.Errorf("unsupported arch '%s' for '%s' node. Supported archs: %s", nodeArch, node.Name, supportedArchs) } } diff --git a/internal/controller/reconcile_os_test.go b/internal/controller/reconcile_os_test.go new file mode 100644 index 0000000..0f6db3b --- /dev/null +++ b/internal/controller/reconcile_os_test.go @@ -0,0 +1,220 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + lifecyclev1alpha1 "github.com/suse-edge/upgrade-controller/api/v1alpha1" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +func TestValidateOSArch(t *testing.T) { + validArchs := []lifecyclev1alpha1.Arch{ + "x86_64", + "aarch64", + } + + nodes := &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "arm64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "aarch64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node3"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "amd64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node4"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "x86_64"}}, + }, + }} + + assert.NoError(t, validateOSArch(nodes, validArchs)) +} + +func TestValidateOSArch_InvalidArch(t *testing.T) { + archs := []lifecyclev1alpha1.Arch{ + "x86_64", + "risc-v", + } + + assert.PanicsWithValue(t, "unknown arch: risc-v", func() { + _ = validateOSArch(nil, archs) + }) +} + +func TestValidateOSArch_UnsupportedNode(t *testing.T) { + validArchs := []lifecyclev1alpha1.Arch{ + "x86_64", + "aarch64", + } + + nodes := &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "arm64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "aarch64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node3"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "amd64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node4"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "x86_64"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node5"}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{Architecture: "risc-v"}}, + }, + }} + + assert.EqualError(t, validateOSArch(nodes, validArchs), + "unsupported arch 'risc-v' for 'node5' node. Supported archs: [x86_64 aarch64]") +} + +func TestIsOSUpgraded(t *testing.T) { + const osPrettyName = "SUSE Linux Micro 6.0" + + nodeLabels := map[string]string{ + "node-x": "z", + } + + tests := []struct { + name string + nodes *corev1.NodeList + selector labels.Selector + expectedUpgrade bool + }{ + { + name: "All matching nodes upgraded", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Enterprise Micro 5.5"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: true, + }, + { + name: "Unschedulable matching node", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: true}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Enterprise Micro 5.5"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: false, + }, + { + name: "Not ready matching node", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionFalse}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Enterprise Micro 5.5"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: false, + }, + { + name: "Matching node on older OS", + nodes: &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro Micro 5.5"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Labels: nodeLabels}, + Spec: corev1.NodeSpec{Unschedulable: false}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Micro 6.0"}}, + }, + { + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + NodeInfo: corev1.NodeSystemInfo{OSImage: "SUSE Linux Enterprise Micro 5.5"}}, + }, + }, + }, + selector: labels.SelectorFromSet(nodeLabels), + expectedUpgrade: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expectedUpgrade, isOSUpgraded(test.nodes, test.selector, osPrettyName)) + }) + } +} diff --git a/internal/upgrade/os_test.go b/internal/upgrade/os_test.go index b1e7959..a39fc91 100644 --- a/internal/upgrade/os_test.go +++ b/internal/upgrade/os_test.go @@ -80,13 +80,13 @@ func TestOSControlPlanePlan(t *testing.T) { upgradeContainer := upgradePlan.Spec.Upgrade require.NotNil(t, upgradeContainer) - assert.Equal(t, "registry.suse.com/bci/bci-base:15.5", upgradeContainer.Image) + assert.Equal(t, "registry.suse.com/bci/bci-base:15.6", upgradeContainer.Image) assert.Equal(t, []string{"chroot", "/host"}, upgradeContainer.Command) assert.Equal(t, []string{"sh", "/run/system-upgrade/secrets/some-secret/os-upgrade.sh"}, upgradeContainer.Args) assert.Equal(t, "3.1.0", upgradePlan.Spec.Version) assert.EqualValues(t, 1, upgradePlan.Spec.Concurrency) - assert.EqualValues(t, 3600, upgradePlan.Spec.JobActiveDeadlineSecs) + assert.EqualValues(t, 43200, upgradePlan.Spec.JobActiveDeadlineSecs) assert.True(t, upgradePlan.Spec.Cordon) assert.Equal(t, "system-upgrade-controller", upgradePlan.Spec.ServiceAccountName) @@ -149,13 +149,13 @@ func TestOSWorkerPlan(t *testing.T) { upgradeContainer := upgradePlan.Spec.Upgrade require.NotNil(t, upgradeContainer) - assert.Equal(t, "registry.suse.com/bci/bci-base:15.5", upgradeContainer.Image) + assert.Equal(t, "registry.suse.com/bci/bci-base:15.6", upgradeContainer.Image) assert.Equal(t, []string{"chroot", "/host"}, upgradeContainer.Command) assert.Equal(t, []string{"sh", "/run/system-upgrade/secrets/some-secret/os-upgrade.sh"}, upgradeContainer.Args) assert.Equal(t, "3.1.0", upgradePlan.Spec.Version) assert.EqualValues(t, 2, upgradePlan.Spec.Concurrency) - assert.EqualValues(t, 3600, upgradePlan.Spec.JobActiveDeadlineSecs) + assert.EqualValues(t, 43200, upgradePlan.Spec.JobActiveDeadlineSecs) assert.True(t, upgradePlan.Spec.Cordon) assert.Equal(t, "system-upgrade-controller", upgradePlan.Spec.ServiceAccountName)