Repository: kubean-io/kubean
Pull request #118 (2022-09-07 08:56:42)

optimize offlineversion crd operator
Signed-off-by: hang.jiang <hang.jiang@daocloud.io>

<!--  

Thanks for sending a pull request!  Here are some tips for you:

* make sure your commit is signed off

-->

**What type of PR is this?**
<!-- 
Uncomment only one ` /kind <>` line, hit enter to put that in a new line, and remove leading whitespaces from that line:

/kind api-change
/kind bug
/kind cleanup
/kind design
/kind documentation
/kind failing-test
/kind feature
/kind flake
-->

/kind feature

**What this PR does / why we need it**:

* make the cr operator of offlineversion to run

**Which issue(s) this PR fixes**:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #

**Special notes for your reviewer**:




Patch:
diff --git a/api/constants/constants.go b/api/constants/constants.go
new file mode 100644
index 000000000..b024a7058
--- /dev/null
+++ b/api/constants/constants.go
@@ -0,0 +1,5 @@
+package constants
+
+const ComponentsversionGlobalName = "componentsversion-global"
+
+const KubeanClusterLabelKey = "clusterName"
diff --git a/cmd/kubean-operator/app/app.go b/cmd/kubean-operator/app/app.go
index 9bd9eb0fe..5d6282d98 100644
--- a/cmd/kubean-operator/app/app.go
+++ b/cmd/kubean-operator/app/app.go
@@ -9,7 +9,7 @@ import (
 
 	"github.com/kubean-io/kubean/pkg/controllers/cluster"
 	"github.com/kubean-io/kubean/pkg/controllers/clusterops"
-
+	"github.com/kubean-io/kubean/pkg/controllers/offlineversion"
 	"github.com/kubean-io/kubean/pkg/util"
 	"github.com/kubean-io/kubean/pkg/version"
 
@@ -20,6 +20,8 @@ import (
 	"k8s.io/klog/v2"
 	kubeanClusterClientSet "kubean.io/api/generated/kubeancluster/clientset/versioned"
 	kubeanClusterOpsClientSet "kubean.io/api/generated/kubeanclusterops/clientset/versioned"
+	kubeancomponentsversionClientSet "kubean.io/api/generated/kubeancomponentsversion/clientset/versioned"
+	kubeanofflineversionClientSet "kubean.io/api/generated/kubeanofflineversion/clientset/versioned"
 	controllerruntime "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/healthz"
 )
@@ -27,7 +29,7 @@ import (
 func NewCommand(ctx context.Context) *cobra.Command {
 	opts := NewOptions()
 	cmd := &cobra.Command{
-		Use:  "kubean-operator", // todo docker build 的二进制名称
+		Use:  "kubean-operator",
 		Long: "run operator for KuBeanCluster and KuBeanClusterOps",
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if errs := opts.Validate(); len(errs) != 0 {
@@ -112,14 +114,14 @@ func setupManager(mgr controllerruntime.Manager, opt *Options, stopChan <-chan s
 	if err != nil {
 		return err
 	}
-	//componentsversionClientSet, err := kubeancomponentsversionClientSet.NewForConfig(resetConfig)
-	//if err != nil {
-	//	return err
-	//}
-	//offlineversionClientSet, err := kubeanofflineversionClientSet.NewForConfig(resetConfig)
-	//if err != nil {
-	//	return err
-	//}
+	componentsversionClientSet, err := kubeancomponentsversionClientSet.NewForConfig(resetConfig)
+	if err != nil {
+		return err
+	}
+	offlineversionClientSet, err := kubeanofflineversionClientSet.NewForConfig(resetConfig)
+	if err != nil {
+		return err
+	}
 	clusterController := &cluster.Controller{
 		Client:              mgr.GetClient(),
 		ClientSet:           ClientSet,
@@ -142,16 +144,15 @@ func setupManager(mgr controllerruntime.Manager, opt *Options, stopChan <-chan s
 		return err
 	}
 
-	//// todo
-	//componentsVersionController := &offlineversion.Controller{
-	//	Client:                     mgr.GetClient(),
-	//	ClientSet:                  ClientSet,
-	//	ComponentsversionClientSet: componentsversionClientSet,
-	//	OfflineversionClientSet:    offlineversionClientSet,
-	//}
-	//if err := componentsVersionController.SetupWithManager(mgr); err != nil {
-	//	klog.Errorf("ControllerManager OfflineVersion but %s", err)
-	//	return err
-	//}
+	componentsVersionController := &offlineversion.Controller{
+		Client:                     mgr.GetClient(),
+		ClientSet:                  ClientSet,
+		ComponentsversionClientSet: componentsversionClientSet,
+		OfflineversionClientSet:    offlineversionClientSet,
+	}
+	if err := componentsVersionController.SetupWithManager(mgr); err != nil {
+		klog.Errorf("ControllerManager OfflineVersion but %s", err)
+		return err
+	}
 	return nil
 }
diff --git a/pkg/controllers/clusterops/controller.go b/pkg/controllers/clusterops/controller.go
index 4c4139bf0..b37308393 100644
--- a/pkg/controllers/clusterops/controller.go
+++ b/pkg/controllers/clusterops/controller.go
@@ -22,6 +22,7 @@ import (
 	"kubean.io/api/apis"
 	kubeanclusterv1alpha1 "kubean.io/api/apis/kubeancluster/v1alpha1"
 	kubeanclusteropsv1alpha1 "kubean.io/api/apis/kubeanclusterops/v1alpha1"
+	"kubean.io/api/constants"
 	kubeanClusterClientSet "kubean.io/api/generated/kubeancluster/clientset/versioned"
 	kubeanClusterOpsClientSet "kubean.io/api/generated/kubeanclusterops/clientset/versioned"
 	controllerruntime "sigs.k8s.io/controller-runtime"
@@ -29,9 +30,8 @@ import (
 )
 
 const (
-	RequeueAfter          = time.Millisecond * 500
-	LoopForJobStatus      = time.Second * 3
-	KubeanClusterLabelKey = "clusterName"
+	RequeueAfter     = time.Millisecond * 500
+	LoopForJobStatus = time.Second * 3
 )
 
 type Controller struct {
@@ -151,7 +151,7 @@ func (c *Controller) FetchJobStatus(clusterOps *kubeanclusteropsv1alpha1.KuBeanC
 }
 
 func (c *Controller) ListClusterOps(clusterName string) ([]kubeanclusteropsv1alpha1.KuBeanClusterOps, error) {
-	list, err := c.KubeanClusterOpsSet.KubeanclusteropsV1alpha1().KuBeanClusterOps().List(context.Background(), metav1.ListOptions{LabelSelector: labels.SelectorFromSet(map[string]string{KubeanClusterLabelKey: clusterName}).String()})
+	list, err := c.KubeanClusterOpsSet.KubeanclusteropsV1alpha1().KuBeanClusterOps().List(context.Background(), metav1.ListOptions{LabelSelector: labels.SelectorFromSet(map[string]string{constants.KubeanClusterLabelKey: clusterName}).String()})
 	if err != nil {
 		return nil, err
 	}
@@ -572,9 +572,9 @@ func (c *Controller) BackUpDataRef(clusterOps *kubeanclusteropsv1alpha1.KuBeanCl
 		return false, fmt.Errorf("cluster %s DataRef has empty value", cluster.Name)
 	}
 	if clusterOps.Labels == nil {
-		clusterOps.Labels = map[string]string{KubeanClusterLabelKey: cluster.Name}
+		clusterOps.Labels = map[string]string{constants.KubeanClusterLabelKey: cluster.Name}
 	} else {
-		clusterOps.Labels[KubeanClusterLabelKey] = cluster.Name
+		clusterOps.Labels[constants.KubeanClusterLabelKey] = cluster.Name
 	}
 	if clusterOps.Spec.HostsConfRef.IsEmpty() {
 		newConfigMap, err := c.CopyConfigMap(clusterOps, cluster.Spec.HostsConfRef, cluster.Spec.HostsConfRef.Name+timestamp)
diff --git a/pkg/controllers/offlineversion/controller.go b/pkg/controllers/offlineversion/controller.go
index d407e98b5..d0e03c353 100644
--- a/pkg/controllers/offlineversion/controller.go
+++ b/pkg/controllers/offlineversion/controller.go
@@ -6,6 +6,7 @@ import (
 
 	kubeancomponentsversionv1alpha1 "kubean.io/api/apis/kubeancomponentsversion/v1alpha1"
 	kubeanofflineversionv1alpha1 "kubean.io/api/apis/kubeanofflineversion/v1alpha1"
+	"kubean.io/api/constants"
 	kubeancomponentsversionClientSet "kubean.io/api/generated/kubeancomponentsversion/clientset/versioned"
 	kubeanofflineversionClientSet "kubean.io/api/generated/kubeanofflineversion/clientset/versioned"
 
@@ -18,9 +19,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-const Loop = time.Second * 30
-
-const ComponentsversionGlobalName = "componentsversion-global"
+const Loop = time.Second * 20
 
 type Controller struct {
 	client.Client
@@ -36,14 +35,14 @@ func (c *Controller) Start(ctx context.Context) error {
 }
 
 func (c *Controller) FetchGlobalKuBeanComponentsVersion() (*kubeancomponentsversionv1alpha1.KuBeanComponentsVersion, error) {
-	componentsVersion, err := c.ComponentsversionClientSet.KubeanV1alpha1().KuBeanComponentsVersions().Get(context.Background(), ComponentsversionGlobalName, metav1.GetOptions{})
+	componentsVersion, err := c.ComponentsversionClientSet.KubeanV1alpha1().KuBeanComponentsVersions().Get(context.Background(), constants.ComponentsversionGlobalName, metav1.GetOptions{})
 	if err != nil {
 		return nil, err
 	}
 	return componentsVersion, nil
 }
 
-func (c *Controller) MergeOfflineVersion(offlineVersion *kubeanofflineversionv1alpha1.KuBeanOfflineVersion, componentsVersion *kubeancomponentsversionv1alpha1.KuBeanComponentsVersion) (bool, *kubeancomponentsversionv1alpha1.KuBeanComponentsVersion) {
+func (c *Controller) MergeOfflineVersionStatus(offlineVersion *kubeanofflineversionv1alpha1.KuBeanOfflineVersion, componentsVersion *kubeancomponentsversionv1alpha1.KuBeanComponentsVersion) (bool, *kubeancomponentsversionv1alpha1.KuBeanComponentsVersion) {
 	updated := false
 	for _, dockerInfo := range offlineVersion.Spec.Docker {
 		if componentsVersion.Status.Offline.MergeDockerInfo(dockerInfo.OS, dockerInfo.VersionRange) {
@@ -69,12 +68,12 @@ func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Reques
 	}
 	componentsVersion, err := c.FetchGlobalKuBeanComponentsVersion()
 	if err != nil {
-		klog.Errorf("Fetch componentsversion-global but %s", err)
-		return controllerruntime.Result{RequeueAfter: Loop}, err
+		klog.Errorf("Fetch %s , ignoring %s", constants.ComponentsversionGlobalName, err)
+		return controllerruntime.Result{RequeueAfter: Loop}, nil
 	}
-	if needUpdate, newComponentsVersion := c.MergeOfflineVersion(offlineVersion, componentsVersion); needUpdate {
+	if needUpdate, newComponentsVersion := c.MergeOfflineVersionStatus(offlineVersion, componentsVersion); needUpdate {
 		klog.Info("Update componentsVersion")
-		if _, err := c.ComponentsversionClientSet.KubeanV1alpha1().KuBeanComponentsVersions().Update(context.Background(), newComponentsVersion, metav1.UpdateOptions{}); err != nil {
+		if _, err := c.ComponentsversionClientSet.KubeanV1alpha1().KuBeanComponentsVersions().UpdateStatus(context.Background(), newComponentsVersion, metav1.UpdateOptions{}); err != nil {
 			klog.Error(err)
 		}
 	}
diff --git a/pkg/util/cluster.go b/pkg/util/cluster.go
index 7bf4c6627..1bf6eeeaa 100644
--- a/pkg/util/cluster.go
+++ b/pkg/util/cluster.go
@@ -8,6 +8,8 @@ import (
 
 	kubeanclusterv1alpha1 "kubean.io/api/apis/kubeancluster/v1alpha1"
 	kubeanclusteropsv1alpha1 "kubean.io/api/apis/kubeanclusterops/v1alpha1"
+	kubeancomponentsversionv1alpha1 "kubean.io/api/apis/kubeancomponentsversion/v1alpha1"
+	kubeanofflineversionv1alpha1 "kubean.io/api/apis/kubeanofflineversion/v1alpha1"
 
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -22,6 +24,8 @@ func init() {
 	_ = scheme.AddToScheme(aggregatedScheme)                   // add Kubernetes schemes
 	_ = kubeanclusteropsv1alpha1.AddToScheme(aggregatedScheme) // add clusterOps schemes
 	_ = kubeanclusterv1alpha1.AddToScheme(aggregatedScheme)    // add cluster schemes
+	_ = kubeancomponentsversionv1alpha1.AddToScheme(aggregatedScheme)
+	_ = kubeanofflineversionv1alpha1.AddToScheme(aggregatedScheme)
 	//_ = clusterapiv1alpha4.AddToScheme(aggregatedScheme) // add cluster-api v1alpha4 schemes
 	//_ = v1alpha2.AddToScheme(aggregatedScheme) // add clusterpedia-api v1alpha2 schemes
 }
diff --git a/vendor/kubean.io/api/constants/constants.go b/vendor/kubean.io/api/constants/constants.go
new file mode 100644
index 000000000..b024a7058
--- /dev/null
+++ b/vendor/kubean.io/api/constants/constants.go
@@ -0,0 +1,5 @@
+package constants
+
+const ComponentsversionGlobalName = "componentsversion-global"
+
+const KubeanClusterLabelKey = "clusterName"
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 46f6d2313..640048c3e 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -1003,6 +1003,7 @@ kubean.io/api/apis/kubeancluster/v1alpha1
 kubean.io/api/apis/kubeanclusterops/v1alpha1
 kubean.io/api/apis/kubeancomponentsversion/v1alpha1
 kubean.io/api/apis/kubeanofflineversion/v1alpha1
+kubean.io/api/constants
 kubean.io/api/generated/kubeancluster/clientset/versioned
 kubean.io/api/generated/kubeancluster/clientset/versioned/fake
 kubean.io/api/generated/kubeancluster/clientset/versioned/scheme


Test patch:
diff --git a/api/apis/kubeancomponentsversion/v1alpha1/types_test.go b/api/apis/kubeancomponentsversion/v1alpha1/types_test.go
index 7e8374965..14c06aeff 100644
--- a/api/apis/kubeancomponentsversion/v1alpha1/types_test.go
+++ b/api/apis/kubeancomponentsversion/v1alpha1/types_test.go
@@ -67,6 +67,22 @@ func TestMerge(t *testing.T) {
 			},
 			want: true,
 		},
+		{
+			name: "update docker-ce info with zero length",
+			args: func() bool {
+				updated := componentsVersion.Status.Offline.MergeDockerInfo("centos0008", nil)
+				return updated && len(componentsVersion.Status.Offline.Docker) == 2 && len(componentsVersion.Status.Offline.Docker[1].VersionRange) == 0
+			},
+			want: true,
+		},
+		{
+			name: "update docker-ce info",
+			args: func() bool {
+				updated := componentsVersion.Status.Offline.MergeDockerInfo("centos0008", []string{"1.1", "1.2", "1.3"})
+				return updated && len(componentsVersion.Status.Offline.Docker) == 2 && len(componentsVersion.Status.Offline.Docker[1].VersionRange) == 3
+			},
+			want: true,
+		},
 	}
 
 	for _, test := range tests {
diff --git a/pkg/controllers/offlineversion/controller_test.go b/pkg/controllers/offlineversion/controller_test.go
index b48fdb942..68a6bbbe2 100644
--- a/pkg/controllers/offlineversion/controller_test.go
+++ b/pkg/controllers/offlineversion/controller_test.go
@@ -1,1 +1,364 @@
 package offlineversion
+
+import (
+	"context"
+	"testing"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	clientsetfake "k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/kubernetes/scheme"
+	controllerruntime "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	kubeancomponentsversionv1alpha1 "kubean.io/api/apis/kubeancomponentsversion/v1alpha1"
+	kubeanofflineversionv1alpha1 "kubean.io/api/apis/kubeanofflineversion/v1alpha1"
+	"kubean.io/api/constants"
+	kubeancomponentsversionv1alpha1fake "kubean.io/api/generated/kubeancomponentsversion/clientset/versioned/fake"
+	kubeanofflineversionv1alpha1fake "kubean.io/api/generated/kubeanofflineversion/clientset/versioned/fake"
+)
+
+func newFakeClient() client.Client {
+	sch := scheme.Scheme
+	if err := kubeancomponentsversionv1alpha1.AddToScheme(sch); err != nil {
+		panic(err)
+	}
+	if err := kubeanofflineversionv1alpha1.AddToScheme(sch); err != nil {
+		panic(err)
+	}
+	client := fake.NewClientBuilder().WithScheme(sch).WithRuntimeObjects(&kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{}).WithRuntimeObjects(&kubeanofflineversionv1alpha1.KuBeanOfflineVersion{}).Build()
+	return client
+}
+
+func TestMergeOfflineVersion(t *testing.T) {
+	controller := &Controller{
+		Client:                     newFakeClient(),
+		ClientSet:                  clientsetfake.NewSimpleClientset(),
+		OfflineversionClientSet:    kubeanofflineversionv1alpha1fake.NewSimpleClientset(),
+		ComponentsversionClientSet: kubeancomponentsversionv1alpha1fake.NewSimpleClientset(),
+	}
+	tests := []struct {
+		name string
+		args struct {
+			OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+			ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+		}
+		updated bool
+	}{
+		{
+			name: "nothing update",
+			args: struct {
+				OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+				ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+			}{
+				OfflineVersion: kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					Spec: kubeanofflineversionv1alpha1.Spec{},
+				},
+				ComponentsVersion: kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					Status: kubeancomponentsversionv1alpha1.Status{
+						Offline: kubeancomponentsversionv1alpha1.OfflineStatus{},
+					},
+				},
+			},
+			updated: false,
+		},
+		{
+			name: "update software info",
+			args: struct {
+				OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+				ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+			}{
+				OfflineVersion: kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Items: []*kubeanofflineversionv1alpha1.SoftwareInfo{
+							{
+								Name:         "etcd-1",
+								VersionRange: []string{"1.1", "1.2"},
+							},
+						},
+					},
+				},
+				ComponentsVersion: kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					Status: kubeancomponentsversionv1alpha1.Status{
+						Offline: kubeancomponentsversionv1alpha1.OfflineStatus{},
+					},
+				},
+			},
+			updated: true,
+		},
+		{
+			name: "update software info",
+			args: struct {
+				OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+				ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+			}{
+				OfflineVersion: kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Items: []*kubeanofflineversionv1alpha1.SoftwareInfo{
+							{
+								Name:         "etcd-1",
+								VersionRange: []string{"1.1", "1.2"},
+							},
+						},
+					},
+				},
+				ComponentsVersion: kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					Status: kubeancomponentsversionv1alpha1.Status{
+						Offline: kubeancomponentsversionv1alpha1.OfflineStatus{
+							Items: []*kubeancomponentsversionv1alpha1.SoftwareInfoStatus{
+								{
+									Name:         "etcd-1",
+									VersionRange: []string{"1.2", "1.3"},
+								},
+							},
+						},
+					},
+				},
+			},
+			updated: true,
+		},
+		{
+			name: "add software info but nothing updated",
+			args: struct {
+				OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+				ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+			}{
+				OfflineVersion: kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Items: []*kubeanofflineversionv1alpha1.SoftwareInfo{
+							{
+								Name:         "etcd-1",
+								VersionRange: []string{"1.1", "1.2"},
+							},
+						},
+					},
+				},
+				ComponentsVersion: kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					Status: kubeancomponentsversionv1alpha1.Status{
+						Offline: kubeancomponentsversionv1alpha1.OfflineStatus{
+							Items: []*kubeancomponentsversionv1alpha1.SoftwareInfoStatus{
+								{
+									Name:         "etcd-1",
+									VersionRange: []string{"1.1", "1.2", "1.3"},
+								},
+							},
+						},
+					},
+				},
+			},
+			updated: false,
+		},
+		{
+			name: "update docker-ce info",
+			args: struct {
+				OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+				ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+			}{
+				OfflineVersion: kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Docker: []*kubeanofflineversionv1alpha1.DockerInfo{
+							{
+								OS:           "redhat-7",
+								VersionRange: []string{"20.01", "20.02"},
+							},
+						},
+						Items: []*kubeanofflineversionv1alpha1.SoftwareInfo{
+							{
+								Name:         "etcd-1",
+								VersionRange: []string{"1.1", "1.2"},
+							},
+						},
+					},
+				},
+				ComponentsVersion: kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					Status: kubeancomponentsversionv1alpha1.Status{
+						Offline: kubeancomponentsversionv1alpha1.OfflineStatus{
+							Docker: []*kubeancomponentsversionv1alpha1.DockerInfoStatus{
+								{
+									OS:           "redhat-8",
+									VersionRange: []string{},
+								},
+							},
+							Items: []*kubeancomponentsversionv1alpha1.SoftwareInfoStatus{
+								{
+									Name:         "etcd-1",
+									VersionRange: []string{"1.1", "1.2"},
+								},
+							},
+						},
+					},
+				},
+			},
+			updated: true,
+		},
+		{
+			name: "nothing updated",
+			args: struct {
+				OfflineVersion    kubeanofflineversionv1alpha1.KuBeanOfflineVersion
+				ComponentsVersion kubeancomponentsversionv1alpha1.KuBeanComponentsVersion
+			}{
+				OfflineVersion: kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Docker: []*kubeanofflineversionv1alpha1.DockerInfo{
+							{
+								OS:           "redhat-7",
+								VersionRange: []string{"20.01", "20.02"},
+							},
+						},
+						Items: []*kubeanofflineversionv1alpha1.SoftwareInfo{
+							{
+								Name:         "etcd-1",
+								VersionRange: []string{"1.1", "1.2"},
+							},
+						},
+					},
+				},
+				ComponentsVersion: kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					Status: kubeancomponentsversionv1alpha1.Status{
+						Offline: kubeancomponentsversionv1alpha1.OfflineStatus{
+							Docker: []*kubeancomponentsversionv1alpha1.DockerInfoStatus{
+								{
+									OS:           "redhat-7",
+									VersionRange: []string{"20.02", "20.01"},
+								},
+								{
+									OS:           "redhat-8",
+									VersionRange: []string{"21.02", "21.01"},
+								},
+							},
+							Items: []*kubeancomponentsversionv1alpha1.SoftwareInfoStatus{
+								{
+									Name:         "etcd-1",
+									VersionRange: []string{"1.2", "1.1"},
+								},
+							},
+						},
+					},
+				},
+			},
+			updated: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if updated, _ := controller.MergeOfflineVersionStatus(&test.args.OfflineVersion, &test.args.ComponentsVersion); updated != test.updated {
+				t.Fatal()
+			}
+		})
+	}
+}
+
+func TestReconcile(t *testing.T) {
+	tests := []struct {
+		name string
+		args func() bool
+		want bool
+	}{
+		{
+			name: "merge successfully",
+			args: func() bool {
+				controller := &Controller{
+					Client:                     newFakeClient(),
+					ClientSet:                  clientsetfake.NewSimpleClientset(),
+					OfflineversionClientSet:    kubeanofflineversionv1alpha1fake.NewSimpleClientset(),
+					ComponentsversionClientSet: kubeancomponentsversionv1alpha1fake.NewSimpleClientset(),
+				}
+				offlineVersionData := kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					TypeMeta: metav1.TypeMeta{
+						Kind:       "KuBeanOfflineVersion",
+						APIVersion: "kubean.io/v1alpha1",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "offlineversion-1",
+					},
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Docker: []*kubeanofflineversionv1alpha1.DockerInfo{
+							{
+								OS:           "redhat-7",
+								VersionRange: []string{"20.1", "20.2"},
+							},
+						},
+					},
+				}
+
+				globalComponentsVersion := kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{
+					TypeMeta: metav1.TypeMeta{
+						Kind:       "KuBeanComponentsVersion",
+						APIVersion: "kubean.io/v1alpha1",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: constants.ComponentsversionGlobalName,
+					},
+				}
+
+				controller.Create(context.Background(), &offlineVersionData)
+				controller.OfflineversionClientSet.KubeanV1alpha1().KuBeanOfflineVersions().Create(context.Background(), &offlineVersionData, metav1.CreateOptions{})
+				controller.ComponentsversionClientSet.KubeanV1alpha1().KuBeanComponentsVersions().Create(context.Background(), &globalComponentsVersion, metav1.CreateOptions{})
+
+				result, err := controller.Reconcile(context.Background(), controllerruntime.Request{NamespacedName: types.NamespacedName{Name: offlineVersionData.Name}})
+				newGlobalComponentsVersion, _ := controller.ComponentsversionClientSet.KubeanV1alpha1().KuBeanComponentsVersions().Get(context.Background(), constants.ComponentsversionGlobalName, metav1.GetOptions{})
+				return err == nil && result.RequeueAfter == Loop && len(newGlobalComponentsVersion.Status.Offline.Docker) == 1 && len(newGlobalComponentsVersion.Status.Offline.Docker[0].VersionRange) == 2
+			},
+			want: true,
+		},
+		{
+			name: "ComponentsversionGlobal not exist",
+			args: func() bool {
+				controller := &Controller{
+					Client:                     newFakeClient(),
+					ClientSet:                  clientsetfake.NewSimpleClientset(),
+					OfflineversionClientSet:    kubeanofflineversionv1alpha1fake.NewSimpleClientset(),
+					ComponentsversionClientSet: kubeancomponentsversionv1alpha1fake.NewSimpleClientset(),
+				}
+				offlineVersionData := kubeanofflineversionv1alpha1.KuBeanOfflineVersion{
+					TypeMeta: metav1.TypeMeta{
+						Kind:       "KuBeanOfflineVersion",
+						APIVersion: "kubean.io/v1alpha1",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "offlineversion-1",
+					},
+					Spec: kubeanofflineversionv1alpha1.Spec{
+						Docker: []*kubeanofflineversionv1alpha1.DockerInfo{
+							{
+								OS:           "redhat-7",
+								VersionRange: []string{"20.1", "20.2"},
+							},
+						},
+					},
+				}
+
+				controller.Create(context.Background(), &offlineVersionData)
+				controller.OfflineversionClientSet.KubeanV1alpha1().KuBeanOfflineVersions().Create(context.Background(), &offlineVersionData, metav1.CreateOptions{})
+
+				result, _ := controller.Reconcile(context.Background(), controllerruntime.Request{NamespacedName: types.NamespacedName{Name: offlineVersionData.Name}})
+				return result.RequeueAfter == Loop
+			},
+			want: true,
+		},
+		{
+			name: "offlineVersion not found",
+			args: func() bool {
+				controller := &Controller{
+					Client:                     newFakeClient(),
+					ClientSet:                  clientsetfake.NewSimpleClientset(),
+					OfflineversionClientSet:    kubeanofflineversionv1alpha1fake.NewSimpleClientset(),
+					ComponentsversionClientSet: kubeancomponentsversionv1alpha1fake.NewSimpleClientset(),
+				}
+				result, _ := controller.Reconcile(context.Background(), controllerruntime.Request{NamespacedName: types.NamespacedName{Name: "offlineversion-1"}})
+				return result.Requeue == false && result.RequeueAfter == 0
+			},
+			want: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if test.args() != test.want {
+				t.Fatal()
+			}
+		})
+	}
+}
diff --git a/pkg/util/cluster_test.go b/pkg/util/cluster_test.go
new file mode 100644
index 000000000..27d00461c
--- /dev/null
+++ b/pkg/util/cluster_test.go
@@ -0,0 +1,44 @@
+package util
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	kubeancomponentsversionv1alpha1 "kubean.io/api/apis/kubeancomponentsversion/v1alpha1"
+	kubeanofflineversionv1alpha1 "kubean.io/api/apis/kubeanofflineversion/v1alpha1"
+)
+
+func TestNewSchema(t *testing.T) {
+	aggregatedScheme := NewSchema()
+	tests := []struct {
+		name    string
+		obj     runtime.Object
+		wantGVK schema.GroupVersionKind
+	}{
+		{
+			name:    "KuBeanComponentsVersion gvk",
+			obj:     &kubeancomponentsversionv1alpha1.KuBeanComponentsVersion{},
+			wantGVK: schema.GroupVersionKind{Group: "kubean.io", Version: "v1alpha1", Kind: "KuBeanComponentsVersion"},
+		},
+		{
+			name:    "KuBeanOfflineVersion gvk",
+			obj:     &kubeanofflineversionv1alpha1.KuBeanOfflineVersion{},
+			wantGVK: schema.GroupVersionKind{Group: "kubean.io", Version: "v1alpha1", Kind: "KuBeanOfflineVersion"},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if gvkArray, _, _ := aggregatedScheme.ObjectKinds(test.obj); gvkArray[0] != test.wantGVK {
+				t.Fatal()
+			}
+		})
+	}
+}
+
+func TestGetCurrentNSOrDefault(t *testing.T) {
+	if namespace := GetCurrentNSOrDefault(); namespace == "" {
+		t.Fatal()
+	}
+}